# `AgentWorkshop.Workshop`
[🔗](https://github.com/joshrotenberg/agent_workshop/blob/main/lib/agent_workshop/workshop.ex#L1)

Multi-agent IEx API for coordinating LLM CLI sessions.

A naming and coordination layer over backend-specific session processes.
Each agent is a supervised process addressed by name. All functions
are designed to be called interactively after `import AgentWorkshop.Workshop`.

## Quick start

    # 1. Set backend and config (do this first -- affects all new agents)
    configure(backend: MyApp.ClaudeBackend,
      backend_config: %{working_dir: "~/projects/myapp"},
      context: "Elixir project. Run mix test before committing.")

    # 2. Create agents with roles
    agent(:impl, "You write clean, tested code.", max_turns: 15)
    agent(:reviewer, "You review code. Do not modify files.",
      model: "opus", allowed_tools: ["Read", "Bash"])

    # 3. Talk to them
    ask(:impl, "Implement caching for the user lookup")
    pipe(:impl, :reviewer, "Review for correctness")

    # 4. Check on things
    status()       # dashboard table
    total_cost()   # how much you've spent

## Sync vs async

Two ways to send messages:

  * `ask/2` -- blocks until the agent responds, prints the result
  * `cast/2` -- returns immediately, agent works in the background

Use `await/1` or `await_all/0` to collect async results. Use `status/0`
to see who's busy.

## Coordination

  * `pipe/3` -- wait for one agent, forward its result to another
  * `fan/2` -- send the same message to multiple agents in parallel

`ask/2` and `pipe/3` return the agent name on success, so they
compose with Elixir's pipe operator:

    ask(:impl, "implement caching")
    |> pipe(:reviewer, "review for edge cases")
    |> pipe(:tests, "write tests for this")

## Inspecting agents

  * `status/0` -- dashboard of all agents
  * `info/1` -- detailed map for one agent (model, session_id, cost, ...)
  * `result/1` -- last response text (or pass `:full` for the full result map)
  * `history/1` -- print the full conversation
  * `cost/0` -- itemized costs; `cost/1` -- single agent; `total_cost/0` -- sum

## Lifecycle

  * `load/0` -- load `.workshop.exs` (auto-loaded on startup if present)
  * `load/1` -- load a specific setup file
  * `reset/1` -- clear an agent's conversation (keeps role and config)
  * `dismiss/1` -- remove an agent entirely
  * `reset_all/0` -- stop everything, clear config

## Architecture

The OTP Application owns the full lifecycle:
ETS tables, registries, supervisors, and the EventLog. Workshop is the
public IEx facade -- it reads and writes shared state but does not start
or supervise any infrastructure. Call `configure/1` to set your backend;
everything else is already running.

    Application supervisor (started automatically)
      |-- TableManager (owns ETS tables)
      |-- :persistent_term (global config -- no process needed)
      |-- Registry (PubSub, Scheduler, BoardWorker)
      |-- EventLog
      |-- DynamicSupervisor (agent sessions)
      `-- Task.Supervisor (async cast tasks)

    Workshop (this module -- IEx helpers, no supervision duties)

# `agent`

```elixir
@spec agent(atom(), String.t() | nil, keyword()) :: :ok
```

Start a named agent.

The role string is agent-specific context that composes with the global
context from `configure/1`. If both are set, the effective system prompt
is `context <> "\n\n" <> role`. Agent-specific options override globals.

Calling `agent/3` with an existing name replaces that agent.

## Examples

    # Full agent with role and options
    agent(:impl, "You write clean, well-tested code.", max_turns: 15)

    # Read-only reviewer using opus
    agent(:reviewer, "You review code. Do not modify files.",
      model: "opus", allowed_tools: ["Read", "Bash"])

    # Minimal -- inherits everything from configure()
    agent(:scratch)

    # Options without a role
    agent(:fast, model: "haiku", max_turns: 3)

## Permissions

Workshop defaults to `permission_mode: :auto` so agents can work
non-interactively. Override per-agent or globally via `configure/1`:

    # Global: all agents use bypass
    configure(permission_mode: :bypass_permissions)

    # Per-agent: restrict the reviewer to default
    agent(:reviewer, "Review only.", permission_mode: :default)

Valid modes: `:default`, `:accept_edits`, `:bypass_permissions`,
`:dont_ask`, `:plan`, `:auto`.

## Options

Core: `:model`, `:max_turns`, `:permission_mode`, `:max_budget_usd`, `:effort`

Permissions: `:allowed_tools`, `:disallowed_tools`, `:dangerously_skip_permissions`

Backend pass-through (Claude CLI): `:worktree`, `:mcp_config`, `:add_dir`,
`:settings`, `:append_system_prompt`, `:no_session_persistence`

Workshop-specific: `:workshop_tools`, `:skill`, `:max_cost_usd`, `:timeout`

# `agents`

```elixir
@spec agents() :: [atom()]
```

List all agent names, sorted alphabetically.

## Example

    agents()    # => [:impl, :reviewer, :tests]

# `ask`

```elixir
@spec ask(atom(), String.t()) :: atom() | {:error, term()}
```

Send a message and wait for the response. Prints the result when done.

This is the primary way to talk to an agent. Each call continues the
agent's conversation (the session ID is threaded automatically).

If the agent has a pending async task from `cast/2`, waits for it first.

Returns the agent name on success, enabling `|>` chaining with `pipe/3`:

    ask(:impl, "implement caching")
    |> pipe(:reviewer, "review for edge cases")

## Examples

    ask(:impl, "What modules are in this project?")
    ask(:impl, "Now add tests for the retry module")   # continues conversation
    ask(:impl, "What files did you change?")            # still the same session

# `await`

```elixir
@spec await(atom(), timeout()) :: :ok | {:error, term()}
```

Wait for an async agent to finish. Prints the result when done.

If the agent is already idle, prints a note and returns `:ok`.
Pass a timeout in milliseconds to avoid blocking forever.

## Examples

    cast(:impl, "Work on this")
    # ... later ...
    await(:impl)              # block until done
    await(:impl, 30_000)      # give up after 30 seconds

# `await_all`

```elixir
@spec await_all(timeout()) :: :ok
```

Wait for all busy agents to finish. Prints each result as it arrives.

## Example

    cast(:impl, "Implement feature")
    cast(:tests, "Write tests")
    await_all()    # blocks until both are done

# `board`

```elixir
@spec board(keyword()) :: [AgentWorkshop.Work.t()]
```

Show the work board. Optionally filter by status or type.

## Examples

    board()                    # full board
    board(status: :ready)      # items ready to pick up
    board(type: :code)         # only code tasks

# `board_worker`

```elixir
@spec board_worker(atom(), atom(), keyword()) :: :ok
```

Create a board worker — an agent that polls the board for work.

Combines a profile, an agent, and a poll loop. The worker claims
ready items matching its work type, executes them, and marks them
complete.

## Options

  * `:profile` - (required) profile name to create the agent from
  * `:interval` - poll interval in ms (default: 60_000 / 1 min)
  * `:worktree` - when `true`, passes `worktree: true` to the agent's query opts,
    enabling the Claude CLI `--worktree` flag for isolated parallel execution

## Examples

    board_worker(:coder_1, :code, profile: :coder, interval: :timer.minutes(1))
    board_worker(:reviewer_1, :review, profile: :reviewer, interval: :timer.minutes(2))

    # Post work — workers pick it up automatically
    work(:feature, "Implement feature X", type: :code, spec: "...")

# `budget`

```elixir
@spec budget(atom() | :global) :: map()
```

Show budget info. With no argument, shows global. With an agent name, shows per-agent.

## Examples

    budget()          # global budget info
    budget(:impl)     # per-agent budget

# `cancel`

```elixir
@spec cancel(atom()) :: :ok
```

Cancel a recurring schedule for an agent.

Stops the agent from executing its scheduled prompt. The agent itself
remains available for manual interaction. No-op if the agent has no
active schedule.

## Example

    iex> every(:monitor, "Check CI status", interval: :timer.minutes(5))
    iex> cancel(:monitor)
    :ok

# `cancel_work`

```elixir
@spec cancel_work(atom()) :: :ok | {:error, term()}
```

Cancel a work item, removing it from active consideration.

Transitions the item to `:cancelled` status. Unlike `fail_work/2`, this
indicates the work was intentionally abandoned rather than attempted
and failed.

## Example

    iex> work(:cache, "Implement LRU cache", type: :code)
    iex> cancel_work(:cache)
    :ok

# `cast`

```elixir
@spec cast(atom(), String.t()) :: :ok | {:error, :queue_full}
```

Send a message asynchronously. Returns `:ok` immediately.

The agent works in the background while you do other things.
Use `status/0` to check progress, `await/1` to collect the result.

If the agent is already working, the message is queued (up to
5 deep). Queued messages are processed in order
after the current task finishes.

## Examples

    cast(:impl, "Implement the caching layer from issue #12")
    cast(:tests, "Add property-based tests for lib/myapp/encoder.ex")

    # Queue a follow-up while :impl is still working
    cast(:impl, "Also add the cache invalidation")

    # Go think about something else...
    status()        # see who's done (shows queue depth)
    await(:impl)    # block until current + queued work finishes
    await_all()     # wait for everyone

# `claim_work`

```elixir
@spec claim_work(atom(), atom()) :: :ok | {:error, term()}
```

Claim a work item for an agent.

## Example

    claim(:cache, :impl)

# `clear_events`

```elixir
@spec clear_events() :: :ok
```

Clear all recorded events from the event log.

After calling this, `events/0` will show no entries until new events occur.

## Example

    iex> clear_events()
    :ok

# `complete_work`

```elixir
@spec complete_work(atom(), String.t() | nil) :: :ok | {:error, term()}
```

Mark a work item as done. Unblocks any dependents.

## Examples

    complete_work(:cache)
    complete_work(:cache, "Implemented with GenServer-backed LRU")

# `configure`

```elixir
@spec configure(keyword()) :: :ok
```

Set global defaults inherited by all agents. Call this first.

Changes affect new agents only; existing agents keep their config.

## Examples

    # Minimal
    configure(backend: MyApp.ClaudeBackend, backend_config: %{working_dir: "."})

    # Full setup
    configure(
      backend: MyApp.ClaudeBackend,
      backend_config: %{working_dir: "~/projects/myapp"},
      model: "sonnet",
      max_turns: 10,
      context: """
      Elixir project using Phoenix 1.7.
      Run mix test before considering any task complete.
      Use conventional commits.
      """
    )

## Permissions

Workshop defaults to `permission_mode: :auto` so agents can work
non-interactively (the CLI default of `:default` would hang waiting
for approval that never comes). Override here to change for all agents:

    configure(permission_mode: :bypass_permissions)

## Options

Backend options:

  * `:backend` -- module implementing `AgentWorkshop.Backend` (required on first call)
  * `:backend_config` -- backend-specific configuration (passed to `start_session/2`)

Query options: `:model`, `:max_turns`, `:permission_mode`, `:max_budget_usd`, `:effort`

Special:

  * `:context` -- global system prompt prepended to every agent's role.
    The effective system prompt for each agent is `context <> "\n\n" <> role`.
  * `:persistence` -- `true` (use `.agent_workshop/` in cwd), a path string,
    or `false` to disable. Saves work board and store to disk on change,
    reloads on next start.

# `cost`

```elixir
@spec cost() :: float()
```

Show itemized costs for all agents, then print the total.

## Example

    cost()
    # :impl: $0.12 (3 turns)
    # :reviewer: $0.24 (1 turn)
    # Total: $0.36

# `cost`

```elixir
@spec cost(atom()) :: float()
```

Show cost for a single agent. Returns the cost as a float.

## Example

    cost(:impl)    # => :impl: $0.12 across 3 turns

# `dashboard`

```elixir
@spec dashboard(keyword()) :: :ok | {:error, term()}
```

Start the LiveView dashboard.

## Options

  * `:port` - HTTP port (default 4223)

## Examples

    dashboard()              # start on port 4223
    dashboard(port: 8080)    # custom port

# `dismiss`

```elixir
@spec dismiss(atom()) :: :ok
```

Remove an agent entirely. Stops its process and discards all state.

No-op if the agent doesn't exist. Use `reset/1` instead if you want
to keep the agent but clear its conversation.

# `events`

```elixir
@spec events(keyword()) :: [map()]
```

Show recent events.

## Options

  * `:last` - number of events to show (default 20)

## Examples

    events()            # last 20
    events(last: 50)    # last 50

# `every`

```elixir
@spec every(atom(), String.t(), keyword()) :: :ok
```

Run an agent prompt on a recurring interval.

## Examples

    every(:monitor, "Check CI status", interval: :timer.minutes(5))
    every(:sweeper, "Clean up stale branches", interval: :timer.hours(1))

# `fail_work`

```elixir
@spec fail_work(atom(), String.t() | nil) :: :ok | {:error, term()}
```

Mark a work item as failed with an optional error message.

Transitions the item to `:failed` status. Unlike `cancel_work/1`, this
indicates the work was attempted but did not succeed.

## Examples

    iex> fail_work(:cache, "Tests failed: 3 assertions")
    :ok

    iex> fail_work(:cache)
    :ok

# `fan`

```elixir
@spec fan(String.t(), [atom()]) :: :ok
```

Send the same message to multiple agents in parallel (all via `cast/2`).

Good for getting multiple perspectives on the same question.

## Example

    fan("What issues do you see in lib/myapp/retry.ex?", [:impl, :reviewer])
    # both agents work concurrently
    await_all()
    result(:impl)       # impl's take
    result(:reviewer)   # reviewer's take

# `from_profile`

```elixir
@spec from_profile(atom(), atom(), keyword()) :: :ok
```

Create an agent from a profile.

## Examples

    profile(:coder, "You write code.", max_turns: 15)
    from_profile(:coder, :coder_bug_42)
    from_profile(:coder, :coder_feature_7, max_turns: 25)  # override opts

# `get`

Get a value from the shared store.

Returns `nil` if the key does not exist. See `get/2` to supply a default.

## Examples

    iex> put(:spec, "LRU cache with TTL")
    iex> get(:spec)
    "LRU cache with TTL"

    iex> get(:nonexistent)
    nil

# `get`

Get a value from the shared store, returning `default` if the key is missing.

## Examples

    iex> get(:missing, "fallback")
    "fallback"

    iex> put(:count, 5)
    iex> get(:count, 0)
    5

# `git_diff`

```elixir
@spec git_diff(keyword()) :: {:ok, String.t()} | {:error, term()}
```

Get uncommitted changes as a diff string.

# `git_summary`

```elixir
@spec git_summary(keyword()) :: {:ok, map()} | {:error, term()}
```

Get a structured summary of the current git state.

Returns branch, status, recent commits, and file changes.
Requires the optional `git` dependency.

## Example

    git_summary()
    # => %{branch: "main", dirty: true, staged: 1, ...}

# `history`

```elixir
@spec history(
  atom(),
  keyword()
) :: :ok
```

Print the conversation history for an agent.

Shows each turn with its cost and result text. Use `:last` to
limit output for long conversations.

## Examples

    history(:impl)           # full conversation
    history(:impl, last: 3)  # only the last 3 turns

# `info`

```elixir
@spec info(atom()) :: map()
```

Get detailed info about an agent as a map.

Returns model, session_id, role, status, cost, turns, and config.

## Example

    info(:impl)
    # => %{name: :impl, status: :idle, model: "sonnet", session_id: "abc-123",
    #       cost: 0.04, turns: 1, role: "You write clean code."}

# `load`

```elixir
@spec load(String.t()) :: :ok | {:error, :not_found}
```

Load a Workshop setup file (`.exs`).

The file is evaluated with `import AgentWorkshop.Workshop` in scope,
so it can call `configure/1`, `agent/3`, etc. directly.

With no argument, looks for `.workshop.exs` in the current directory.

## Example file (.workshop.exs)

    configure(backend: MyApp.ClaudeBackend,
      backend_config: %{working_dir: "."},
      model: "sonnet",
      context: "Elixir project. Run mix test before committing.")

    agent(:impl, "You write clean code.", max_turns: 15)
    agent(:reviewer, "Review only.", model: "opus",
      allowed_tools: ["Read", "Bash"])

## Examples

    load()                        # loads .workshop.exs
    load("setups/review.exs")     # loads a specific file

# `log_level`

```elixir
@spec log_level(Logger.level()) :: :ok
```

Set the log level for Workshop messages.

Useful for quieting debug noise during interactive sessions.

## Examples

    log_level(:warning)    # quiet -- only warnings and errors
    log_level(:info)       # default
    log_level(:debug)      # verbose -- see send/receive for every message

# `mcp_server`

```elixir
@spec mcp_server(keyword() | boolean()) :: :ok | {:error, term()}
```

Start the Workshop MCP server.

Exposes all Workshop functions as MCP tools over HTTP.
Requires `anubis_mcp`, `bandit`, and `plug` deps.

Can also be triggered via `configure(mcp: [port: 4222])`.

## Examples

    mcp_server()                # start on default port 4222
    mcp_server(port: 8080)      # custom port

# `pipe`

```elixir
@spec pipe(atom() | {:error, term()}, atom(), String.t() | nil) ::
  atom() | {:error, term()}
```

Wait for `from` to finish, then send its last result to `to`.

The `message` argument frames what `to` should do with the result.
The forwarded text is appended after your message. Without a message,
a default framing is used.

This is synchronous -- it blocks until both agents are done.

Returns the target agent name on success, so pipes chain:

    ask(:impl, "implement caching")
    |> pipe(:reviewer, "review for edge cases")
    |> pipe(:tests, "write tests for this")

## Examples

    # Implement, then review
    ask(:impl, "Implement caching for user lookup")
    pipe(:impl, :reviewer, "Review for cache invalidation edge cases")

    # Or after an async cast
    cast(:impl, "Implement the retry logic")
    # ... do other stuff ...
    pipe(:impl, :tests)   # awaits :impl, sends result to :tests

# `profile`

```elixir
@spec profile(atom(), String.t() | nil, keyword()) :: :ok
```

Define a reusable agent profile (template).

## Examples

    profile(:coder, "You write clean code.", max_turns: 15)
    profile(:reviewer, "Review only.", model: "opus", allowed_tools: ["Read", "Bash"])

# `profiles`

```elixir
@spec profiles() :: [{atom(), map()}]
```

List available profiles.

# `put`

Put a value in the shared store.

Keys can be any term. Use tuples for namespacing.

## Examples

    put(:spec, "LRU cache with TTL support")
    put({:impl, :notes}, "Chose GenServer over Agent")

# `reset`

```elixir
@spec reset(atom()) :: :ok
```

Reset an agent's conversation. Keeps the role, model, and all config.

The agent gets a fresh session -- like calling `agent/3` again with
the same arguments. Cost tracking is also reset to zero.

## Example

    ask(:impl, "Write some code")
    # ... not happy with the direction ...
    reset(:impl)
    ask(:impl, "Try a different approach")   # fresh conversation

# `reset_all`

```elixir
@spec reset_all() :: :ok
```

Stop all agents and clear global config. The nuclear option.

After this, you'll need to call `configure/1` and `agent/3` again.

# `reset_budget`

```elixir
@spec reset_budget() :: :ok
```

Reset all budget tracking.

Clears both global and per-agent budget limits and spent totals.
After calling this, agents can spend without budget restrictions
until new limits are set via `configure(max_cost_usd: ...)` or
per-agent `agent(:name, "role", max_cost_usd: ...)`.

## Example

    iex> reset_budget()
    :ok

# `reset_workflow`

```elixir
@spec reset_workflow(atom()) :: :ok | {:error, term()}
```

Reset a workflow -- removes its work items and resets to :defined.

Use this to re-run a workflow or clear a failed run.

## Example

    reset_workflow(:feature)
    run_workflow(:feature)

# `result`

```elixir
@spec result(atom(), :text | :full) :: String.t() | map() | nil
```

Get the last response from an agent.

Returns the response text by default. Pass `:full` to get the
complete result map (includes cost, session_id, duration, etc.).

## Examples

    result(:impl)          # => "Here is the implementation..."
    result(:impl, :full)   # => %{result: "...", cost_usd: 0.04, ...}

# `run_workflow`

```elixir
@spec run_workflow(atom()) :: :ok | {:error, term()}
```

Run a workflow by expanding its stages into work board items.

Board workers will automatically pick up and execute the stages
in dependency order.

## Example

    run_workflow(:feature)

# `schedules`

```elixir
@spec schedules() :: [map()]
```

List active schedules.

# `start_work`

```elixir
@spec start_work(atom()) :: :ok | {:error, term()}
```

Mark a work item as in progress.

Transitions a work item from `:ready` (or `:claimed`) to `:in_progress`.
Typically called after an agent has claimed the item with `claim_work/2`.

## Example

    iex> work(:cache, "Implement LRU cache", type: :code)
    iex> claim_work(:cache, :impl)
    iex> start_work(:cache)
    :ok

# `status`

```elixir
@spec status() :: [map()]
```

Print a status dashboard showing all agents.

## Example output

     agent     | status  | task                                 | cost  | turns
    -----------+---------+--------------------------------------+-------+------
     :impl     | working | Implement the caching layer from ... | $0.08 | 3
     :reviewer | idle    |                                      | $0.00 | 0
     :tests    | idle    |                                      | $0.04 | 2

# `stop`

```elixir
@spec stop() :: :ok
```

Stop the Workshop and clean up all state. Used for teardown in tests.

Dismisses all agents, clears all ETS tables. The Application supervisor
remains running -- call `configure/1` to set up again.

# `store`

Print all entries in the shared store to the console.

Each entry is displayed as `key: value`. Prints "Store is empty." when
the store has no entries.

## Example

    iex> put(:spec, "LRU cache")
    iex> put(:status, :draft)
    iex> store()
      :spec: "LRU cache"
      :status: :draft
    :ok

# `store_delete`

Delete a key from the shared store.

No-op if the key does not exist.

## Examples

    iex> put(:scratch, "temp value")
    iex> store_delete(:scratch)
    :ok
    iex> get(:scratch)
    nil

# `store_keys`

List all keys currently in the shared store.

Returns an empty list when the store has no entries.

## Examples

    iex> put(:spec, "cache design")
    iex> put(:notes, "use GenServer")
    iex> store_keys()
    [:notes, :spec]

    iex> store_keys()
    []

# `total_cost`

```elixir
@spec total_cost() :: float()
```

Get the total cost across all agents as a single number.

# `unwatch`

```elixir
@spec unwatch() :: :ok
```

Stop printing live events.

# `watch`

```elixir
@spec watch() :: :ok
```

Start printing live events to the console.

Shows agent creation/dismissal, ask/cast completions with cost,
work board changes, errors, and more.

## Example

    watch()
    cast(:impl, "do something")
    # [agent] impl created
    # [cast] impl complete ($0.04) — Here is the implementation...

# `work`

```elixir
@spec work(atom(), String.t(), keyword()) :: :ok
```

Add a work item to the board.

## Options

  * `:type` - work type (`:code`, `:review`, `:test`, `:docs`, `:deploy`, `:triage`, `:custom`)
  * `:spec` - detailed specification
  * `:priority` - 1 (highest) to 5 (lowest), default 3
  * `:depends_on` - list of work item IDs that must complete first

## Examples

    work(:cache, "Implement LRU cache",
      type: :code, priority: 1,
      spec: "LRU cache with configurable max size and TTL per entry")

    work(:cache_review, "Review cache implementation",
      type: :review, depends_on: [:cache])

# `work_from_result`

```elixir
@spec work_from_result(atom(), atom(), keyword()) :: :ok
```

Create a work item from an agent's last result.

Uses `result(agent_name)` as the spec for the new work item.
Useful for turning an agent's analysis, plan, or review into
actionable board items.

## Examples

    ask(:arch, "Review the architecture of this project")
    work_from_result(:arch, :refactor, type: :code, title: "Address architecture findings")

    ask(:planner, "Break this feature into tasks")
    work_from_result(:planner, :feature_impl, type: :code)

# `work_item`

```elixir
@spec work_item(atom()) :: AgentWorkshop.Work.t() | nil
```

Get details of a specific work item.

Returns the full work item struct including status, type, priority,
dependencies, claimed agent, and result. Returns `nil` if the item
does not exist.

## Examples

    iex> work(:cache, "Implement LRU cache", type: :code, priority: 1)
    iex> item = work_item(:cache)
    iex> item.title
    "Implement LRU cache"
    iex> item.status
    :ready

    iex> work_item(:nonexistent)
    nil

# `workers`

```elixir
@spec workers() :: [map()]
```

Print a summary of all active board workers.

Board workers are agents that automatically poll the work board for
items matching their work type, claim them, execute them, and mark
them complete. Shows each worker's type, completed count, and current
item (if any). Prints "No board workers." when none are running.

## Example

    iex> workers()
      :coder_1: code, 3 completed
      :reviewer_1: review, 1 completed (working on :cache_review)
    :ok

# `workflow`

```elixir
@spec workflow(atom(), list()) :: :ok | {:error, term()}
```

Define a declarative workflow -- a sequence of stages that expand
into work board items with dependencies.

Each stage is a tuple: `{stage_name, agent, title}` or
`{stage_name, agent, title, opts}`.

## Stage options

  * `:from` - data source: a file path (string), a stage name (atom),
    or a list of stage names (fan-in)
  * `:type` - work board type (default: `:custom`)
  * `:priority` - priority 1-5 (default: 3)

## Example

    workflow(:feature, [
      {:plan, :planner, "Break this into tasks", from: "specs/feature.md"},
      {:implement, :coder, "Implement the plan", from: :plan},
      {:test, :tester, "Write tests", from: :implement},
      {:review, :reviewer, "Review everything", from: [:implement, :test]}
    ])

# `workflow_status`

```elixir
@spec workflow_status(atom()) ::
  {AgentWorkshop.Workflow.t(), [AgentWorkshop.Work.t() | nil]}
  | {:error, term()}
```

Show workflow progress -- each stage with its current status.

## Example

    workflow_status(:feature)

# `workflows`

```elixir
@spec workflows() :: [AgentWorkshop.Workflow.t()]
```

List all defined workflows.

## Example

    workflows()

---

*Consult [api-reference.md](api-reference.md) for complete listing*
