# Workflows

A per-project YAML that declares the statuses a task moves through, who owns each one, and what side-effects fire on each transition. The default workflow is the canonical Backlog → Todo → InProgress → Review → Done board; custom workflows are a YAML edit.

A **workflow** is a YAML file that declares the statuses a task moves
through, who owns each one, and what git side-effects fire on each
transition. `shelbi init` ships two: **`task`**, the review-gated default
(`Backlog → Todo → In Progress → Review → Done`, plus `Canceled`), and
**`subtask`**, a lighter flow for a piece of a parent task that opens no PR
and has no review (see [Shipped workflows](#shipped-workflows-task-and-subtask)
below). Drop another YAML next to them and you have a third pipeline:
a docs-only flow, a research pipeline that never opens PRs, a
feature-stacking flow whose tasks branch off a long-lived feature
branch.

Status *names* are user-facing labels: what shows up on the Kanban
card. Generic code (orchestrator auto-dispatch, Zen Mode's confidence
bar, the events log, the activity feed) keeps working unchanged
because every status maps to a small, closed set of **categories**:
`backlog`, `ready`, `active`, `handoff`, `done`. Categories are the
vocabulary the rest of the system reasons in.

The default workflow renders as the canonical five-column board, one column
per category, each colored by its category:

Schema and parser live in `crates/shelbi-core/src/workflow.rs`.
Workflow files live at
`~/.shelbi/projects/<project>/workflows/<name>.yaml`.

## Choosing the default workflow

A task can pin a workflow explicitly:

```yaml
workflow: feature-task
```

When `workflow:` is absent, Shelbi checks the project config for
`default_workflow:`:

```yaml
# ~/.shelbi/projects/myapp.yaml or <repo>/.shelbi/project.yaml
default_workflow: task
```

That makes tasks without `workflow:` behave as if they declared
`workflow: task`. Fresh projects get `default_workflow: task` written by the
scaffold, so the review-gated `task` flow is the default out of the box. If
the project field is omitted, Shelbi falls back to the built-in `default`.
A configured default must name an existing `workflows/<name>.yaml`; if it does
not, project loading fails with a workflow error instead of silently falling
back.

## Schema

A workflow is split across **two files**. Status *identity* (the stable
`id`, the display `name`, and the `category`) is declared once in
`workflows/statuses.yaml`, the project-wide status catalog. Each workflow
file then **references** statuses by `id` and adds only what's
workflow-specific: the `owner`, and an optional `agent:`.

```yaml
# ~/.shelbi/projects/<project>/workflows/statuses.yaml
# The status catalog: identity lives here, once, shared by every workflow.
statuses:
  - { id: backlog,     name: Backlog,     category: backlog }
  - { id: todo,        name: Todo,        category: ready }
  - { id: in-progress, name: In Progress, category: active }
  - { id: review,      name: Review,      category: handoff }
  - { id: done,        name: Done,        category: done }
```

```yaml
# ~/.shelbi/projects/<project>/workflows/default.yaml
name: default
description: The standard one-track flow shipped with every project.

# Reference statuses by id. name/category come from statuses.yaml and are
# never repeated here — a workflow file adds only owner (+ optional agent).
statuses:
  - { id: backlog,     owner: user,  agent: orchestrator }
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: user,  agent: orchestrator }
  - { id: done,        owner: user }

# Optional. Which status a new task lands in. Defaults to the first
# entry in `statuses:` above.
# initial_status: backlog

# Optional. Per-workflow override of the project-level `git:` block in
# project.yaml. Most workflows omit it and inherit base_branch/merge_strategy
# unchanged.
# git:
#   base_branch: main
#   merge_strategy: squash         # squash | merge

# Optional. Edge-by-edge action declarations. Each entry says what side
# effects to run when a task crosses that edge. Omitted edges run no
# actions — pure status moves. Transitions are any-to-any: this block
# does NOT restrict which moves are legal.
# transitions:
#   - from: in-progress
#     to: review
#     actions: [push_branch, open_pr]
#   - from: review
#     to: done
#     actions: [merge, delete_branch]
```

Why two files? A status's identity is project-wide, but its automation
differs per workflow. The same `review` status can be owned by a human in
one workflow and handled by a `qa` agent in another. Declaring `name` and
`category` once in `statuses.yaml` keeps those facts from drifting between
workflow files; the loader **rejects** a workflow that repeats `name:` or
`category:` inline, or that references an `id` the catalog doesn't declare.

The required fields are `name` and `statuses` (at least one). Everything
else is optional. A workflow without `transitions:` is a pure
bookkeeping workflow: no git side-effects fire on any move.

Validation runs at load time and catches status ids not declared in
`statuses.yaml`, duplicate status ids, `initial_status` pointing at
nothing, transition edges referencing undeclared statuses, and the
[`owner`/`agent` rules](#validation-rules) covered below. The error
message names the workflow and the bad field.

## Status categories

Every status, default or custom, declares one of five categories:

| Category   | Semantic                                                            | Example status names                                  |
| ---------- | ------------------------------------------------------------------- | ----------------------------------------------------- |
| `backlog`  | Not yet ready for work — triage stage.                              | `Backlog`, `Inbox`, `Triage`                          |
| `ready`    | Ready for whoever owns it to pick up.                               | `Todo`, `Ready`, `Next Up`                            |
| `active`   | Owner is working on it now.                                         | `InProgress`, `Building`, `Designing`, `Drafting`     |
| `handoff`  | One owner has finished their part; another's input is required.     | `Review`, `QA`, `Awaiting Sign-off`                   |
| `done`     | Terminal — accepted, shipped.                                       | `Done`, `Shipped`, `Released`                         |

The category set is **closed at five**. A workflow can't introduce a
sixth. Generic code (Zen Mode, activity feed, orchestrator routing)
keys off the category, and an unknown one would have nowhere to fit. A
workflow *can* repeat a category, though: a long pipeline might have
three `active` statuses (Design, Build, QA) with different owners.

Categories let the rest of the system reason without caring about
names:

- The orchestrator dispatches `ready`-category statuses whose `owner`
  is `agent`. It doesn't care whether you called the column `Todo` or
  `Ready` or `Next Up`.
- The hub poller's review-marker promotion lands a task in the next
  `handoff` status: the same code path for `Review`, `QA`,
  `Awaiting Sign-off`.
- The activity feed renders an `active → handoff` move as
  "handed off" regardless of the actual status names involved.

The category for each status appears on every events log line so
consumers can match on the semantic without looking up the workflow.
See [events log line shape](/docs/concepts/events-log#task-transitions).

## Owners and agents

Each status declares two things about who handles it, one required, one
optional:

```yaml
# workflow file — reference-only; name/category come from statuses.yaml
statuses:
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: agent, agent: qa }
  - { id: signoff,     owner: user }
  - { id: done,        owner: user }
```

**`owner: user | agent`** declares *who* the next mover is:

- **`agent`** — the orchestrator is the next mover. Tasks sitting in an
  `agent`-owned `ready` status are eligible for auto-dispatch onto a free
  [workspace](/docs/concepts/workspaces).
- **`user`** — the orchestrator does not act. The task waits for you to
  move it, surfaced in the activity feed so you know your attention is
  requested.

There's no third "either": a status is either work the orchestrator will
pick up or work that waits for a human. If you want to grab an agent-owned
task by hand, reassign it through the normal CLI/TUI; that's an action, not
a schema field.

**`agent: <agent-name>`** names *which* [agent](/docs/concepts/agents) role does
the work, when `owner: agent`. Omit it and Shelbi derives a default from the
category (`developer` for `active`, `orchestrator` for `ready`). Name
one (a shipped reviewer like `qa`, `security`, or `adversarial`, or any
agent you've authored) and the orchestrator loads *that* role into the
workspace it dispatches to. The
`Review` status above is auto-handled by the `qa` agent before any human is
involved; that's how "this status is reviewed by QA" becomes a line of YAML
instead of a paragraph of orchestrator prompt.

### Validation rules

The loader enforces these rules, failing the load with a message that
names the workflow and status:

1. **Every `id` must be declared in `statuses.yaml`.** A workflow
   references statuses by id; an id the catalog doesn't know about (a typo,
   a status you forgot to add) fails the load, and the error lists the
   available ids. Repeating an inline `name:` or `category:` in a workflow
   file is rejected too. Identity lives in the catalog, so the two files
   can't drift.
2. **`owner` is `user` or `agent`.** No other value parses, and a missing
   `owner` is an error too. There's no implicit default.
3. **An `agent`-owned status must name its `agent:`.** For the `ready` and
   `active` categories a bare `owner: agent` is accepted and defaults to
   `orchestrator` / `developer` (with a deprecation warning); any other
   category with `owner: agent` and no `agent:` fails the load.
4. **A `user`-owned status may still name an `agent:`.** That declares
   "under [Zen Mode](/docs/concepts/zen-mode) this agent may act without
   me". It's how the shipped default lets the orchestrator handle
   `Backlog` and `Review` under automation. Terminal statuses (`Done`,
   `Canceled`) simply omit it: no automation path at all.

### Zen as declarative data

Because the `owner`/`agent` pair lives in the workflow YAML, *per-status
automation is data, not prompt prose*. Whether the orchestrator acts at a
given edge is the `owner` field; which agent it uses is the `agent` field.
That makes each of these choices a YAML edit:

- **"Auto-merge but don't auto-promote."** Keep `Backlog` as
  `owner: user` (you still triage), but make the merge-bearing handoff
  status `owner: agent`. The orchestrator lands finished work without
  touching your triage queue. No prompt surgery.
- **"Review with QA, sign off by hand."** Split the handoff into a
  `Review` status (`owner: agent, agent: qa`) and a `Signoff` status
  (`owner: user`), as in the example above. The QA pass is automated; the
  final accept stays human.

The [judgment policy](/docs/concepts/zen-mode#the-judgment-layer) (*which*
backlog items are in scope to promote, and the confidence bar a merge must
clear) still lives in the orchestrator's prompt, because it's a judgment
call. But the structural "is this status automated, and by whom" question is
now answered declaratively in the workflow. See
[Zen Mode](/docs/concepts/zen-mode#per-status-automation-is-declarative).

## Transitions

The `transitions:` block declares **side-effects**, not legal moves.
Cards can be dragged between any two declared statuses; what `transitions:`
controls is what *happens* on the way. An undeclared edge is a pure
status change with no git activity.

Each transition lists actions in execution order. Failures
short-circuit the rest: if `merge` fails, `delete_branch` does not
run.

| Action          | Effect                                                                                                                                                                                                |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `push_branch`   | Push the task's branch to `origin`. No-op if already up to date.                                                                                                                                      |
| `open_pr`       | Open a PR. Base is `transition.target` if set, else the workflow's resolved `base_branch`, else the parent task's branch when `depends_on` is set. No-op if a PR is already open.                     |
| `merge`         | Merge the task's branch with the effective `merge_strategy`. Via `gh pr merge` when a PR is open; via local `git merge` on the hub otherwise. v1 strategies: `squash`, `merge`.                       |
| `close_pr`      | Close any open PR without merging.                                                                                                                                                                    |
| `delete_branch` | Delete the local + remote branch. Skipped if a workspace still has the branch checked out. That worktree replaces the branch on its next dispatch.                                                   |
| `restack`       | Rebase the task's branch onto its parent task's current branch. Fires automatically on dependent tasks when a parent's `merge` completes (see [`depends_on`](#dependent-tasks-with-depends_on)).      |

All actions are **hub-side** and idempotent. The orchestrator (or the
CLI) on the hub machine runs them; workspaces never invoke actions
directly.

The canonical default workflow's transitions, written out:

```yaml
transitions:
  - from: in-progress
    to: review
    actions: [push_branch, open_pr]

  - from: review
    to: done
    actions: [merge, delete_branch]
```

### Per-transition `target:`

Each transition may declare a `target:` field that overrides where
`merge` and `open_pr` land for *that edge only*:

```yaml
transitions:
  - from: in-progress
    to: review
    target: develop                # merge feature work into develop here
    actions: [push_branch, merge]

  - from: review
    to: done
    # no target → uses the workflow's base_branch (e.g., main)
    actions: [open_pr, merge]
```

`target:` does **not** affect where new task branches are cut from.
That's always the resolved `base_branch`. It only controls where
merges land for this particular hop.

### What fires Zen Mode's high-confidence bar

[Zen Mode](/docs/concepts/zen-mode#the-high-confidence-bar) gates *any*
transition whose actions include `merge`, regardless of which
statuses sit on either side. A trunk-based workflow that skips
`Review` and goes straight `Active → Done` with `actions: [merge,
delete_branch]` trips the same probe as the canonical `Review → Done`.
That's the whole point of expressing side-effects as actions: the
gate is action-based, not status-pair-based.

## The `branch:` task field

Every task carries an optional `branch:` field in its frontmatter: the
name of the git branch the task operates on. Its presence at creation
controls whether Shelbi cuts a fresh branch or uses an existing one.

```yaml
---
id: build-login-form
title: Build the login form
workflow: default
# branch:  ← omitted → orchestrator generates jlong/build-login-form on dispatch
---
```

- **Omitted at creation.** When the task moves into its first
  `active`-category status (typically `InProgress`), the orchestrator
  cuts a fresh branch off the workflow's resolved `base_branch`, names it
  by rendering the workflow's (or project's) `git.branch` template (the
  shipped `task` workflow uses `{{github_user}}/{{id}}`, so
  `build-login-form` becomes `jlong/build-login-form`), and writes the name
  back into the task's frontmatter. When no `branch` template is configured,
  Shelbi falls back to your GitHub username. The user never types a branch
  name. This is the common case.
- **Set at creation.** The orchestrator uses that branch as-is. No new
  branch is cut. This is how **release tasks** work: the user already
  knows the branch (typically `feature/<name>`) and pre-fills it.

```yaml
---
id: ship-auth-rewrite
title: Ship the auth rewrite to main
workflow: feature-release
branch: feature/auth-rewrite       # pre-existing — use this branch as-is
---
```

Once populated, `branch:` is the source of truth for every subsequent
action: `push_branch`, `open_pr`, `merge`, and `delete_branch` all
operate on it. Workflows don't carry a separate "what branch to ship"
field; the task does.

## Parameterization

A `git:` block can use `{{var}}` placeholders that resolve from the
task's frontmatter at load time. This is what makes one
`feature-task.yaml` reusable across any number of concurrent features:

```yaml
# workflows/feature-task.yaml
name: feature-task
required_params: [feature]             # every task must set feature:
git:
  base_branch: feature/{{feature}}     # resolved per task
  merge_strategy: squash

statuses:
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: user }
  - { id: done,        owner: user }

transitions:
  - from: in-progress
    to: review
    actions: [push_branch]             # push so reviewers can see; no PR
  - from: review
    to: done
    actions: [merge, delete_branch]    # merge into feature/{{feature}}
```

Two tasks, two features, one workflow file:

```markdown
---
id: build-login-form
title: Build the login form
workflow: feature-task
feature: auth-rewrite            # base_branch resolves to feature/auth-rewrite
---
```

```markdown
---
id: wire-dashboard-shell
title: Wire the dashboard shell
workflow: feature-task
feature: dashboard-v2            # base_branch resolves to feature/dashboard-v2
---
```

Substitution rules:

- Plain string replacement. No conditionals, no expressions, no
  defaults.
- Placeholders are allowed in `git:` field values only. They are not
  resolved inside status names, category names, or the `transitions:`
  shape.
- A missing key produces one clear error per workflow listing every
  unresolved placeholder, so a single edit to the task's frontmatter
  fixes them all: *"workflow `feature-task` requires task params:
  `feature`"*.
- Every `{{var}}` in `base_branch` must be declared in the workflow's
  [`required_params`](/docs/configuration/workflow#required-params).
  Shelbi rejects the workflow at load time otherwise, so a base branch
  that references a field no task carries is caught before dispatch, not
  after.

## Dependent tasks with `depends_on`

Tasks declare a `depends_on:` list in their frontmatter to express a
parent/child relationship. This is how Graphite-style stacked PRs are
modeled: task B builds on top of task A's branch, which builds on
`main`.

```markdown
---
id: build-login-form
title: Build the login form
workflow: default
---
```

```markdown
---
id: build-dashboard
title: Build the dashboard (uses login form)
workflow: default
depends_on: [build-login-form]   # branch cut off build-login-form's branch
---
```

Three behaviors fire when `depends_on:` is set:

1. **Blocked while waiting.** A task is blocked from auto-dispatch
   while any id in `depends_on` is not yet in a `done`-category
   status. The Kanban renders blocked cards with a `🔒` badge; they
   sit in the `ready`-category column until their dependencies clear.
   This is the same gate the default workflow's `Todo` status applies
   to dependent tasks. See the
   [lifecycle walkthrough](#lifecycle-who-moves-a-task-between-the-default-statuses)
   above.
2. **Branch cut from the parent.** When the dependent task transitions
   into its first `active`-category status, the orchestrator cuts its
   branch off the **parent task's `branch:`** (not the workflow's
   `base_branch`). The child branch is still generated as
   `<prefix>/<child-task-id>`.
3. **PR base is the parent.** When the dependent task's `open_pr`
   action runs, the PR's base is the parent task's branch, not the
   workflow's `base_branch`.

A fourth behavior fires *on the parent*:

4. **Auto-restack on parent merge.** When the parent's `merge` action
   completes, the orchestrator iterates every task with
   `depends_on:` containing the parent's id and runs the `restack`
   action on each. The child branch is rebased onto the parent's
   target (typically `main`), and any open PR's base is updated to
   match. A child with multiple parents waits until every parent is
   in a `done`-category status, then restacks once onto the converged
   target. Chains restack transitively.

Cycles in `depends_on:` are rejected at task-load time with a clear
error naming the offending task and the cycle path. A task whose
parent ended up in a non-`done` terminal state (cancelled, won't fix)
is surfaced in the activity feed and won't proceed past the
`ready`-category column until the user resolves it.

## Per-task workflow assignment

Tasks declare which workflow they belong to in frontmatter:

```yaml
---
id: design-the-thing
title: Design the thing
workflow: design-review         # optional; otherwise project default, then "default"
column: Designing               # uses a status name from the assigned workflow
---
```

If `workflow:` is absent, the task uses `default_workflow:` from project
config. If the project does not declare one, Shelbi falls back to `default`.
If an explicit task `workflow:` references a missing definition, Shelbi
falls back to the built-in `default`; a missing configured project default
fails project loading because that would otherwise reroute every implicit
task silently.

A task is bound to its workflow at creation. **Cross-workflow moves are
not allowed.** To switch a task into a different workflow, create a
fresh task in the target workflow (and archive the original if you
want).

## Shipped workflows: task and subtask

Every new project ships two workflow files, and `default_workflow: task`
is written into the project config so `task` is the default.

### task

`workflows/task.yaml` is the review-gated default: a task branches off
`main` as `<your-github-user>/<id>` (the shipped `branch:
'{{github_user}}/{{id}}'` template), moves through `Backlog → Todo → In Progress →
Review → Done` (with a `Canceled` archive status), and squash-merges to
`main` on accept. It opens exactly one PR, on `In Progress → Review`,
which is both the review surface and, once accepted, what lands on `main`.

```yaml
# workflows/task.yaml — references statuses.yaml (see Schema above)
name: task
description: "Branch work with a review gate: the default track shipped with every project."

git:
  base_branch: main
  branch: '{{github_user}}/{{id}}'
  merge_strategy: squash

statuses:
  - { id: backlog,     owner: user,  agent: orchestrator }
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: user,  agent: review, tags: [review] }
  - { id: done,        owner: user }
  - { id: canceled,    owner: user }

transitions:
  - { from: in-progress, to: review,   actions: [push_branch, open_pr] }
  - { from: review,      to: done,     actions: [merge, delete_branch] }
  - { from: in-progress, to: canceled, actions: [close_pr, delete_branch] }
  - { from: review,      to: canceled, actions: [close_pr, delete_branch] }
```

The `Review` status is where the review workspace comes in. It's
`owner: user` (you still own the accept), but it names `agent: review` and
carries `tags: [review]`. When a task lands here, the orchestrator dispatches
it to a **`review`-tagged workspace** and loads the [Reviewer
agent](/docs/concepts/agents), which stands up the running app so you can
click through the branch before you merge. Declare a `review`-tagged workspace
slot on the hub for exactly this (add `tags: [review]` to a workspace in the
project YAML). See
[Review workspaces](/docs/concepts/review-workspaces) for the tag-matching
rules and how the dev server is booted.

To put a shipped reviewer on the board, name it on a status's `agent:`
field. The three reviewer presets (`qa`, `security`, `adversarial`) ship
materialized but unwired, so wiring one in is a one-line edit: point a status
at `agent: qa` (or `security`, or `adversarial`). [Doing more with
agents](/docs/guides/doing-more-with-agents) walks a full example, including
adding a dedicated review column and its transitions.

### subtask

`workflows/subtask.yaml` is for a piece of a parent `task`, done on its own
branch with **no PR and no review**. A subtask names its parent task's id in
its `task:` frontmatter, and the templated `base_branch: task/{{task}}`
resolves to the parent's branch, so the subtask branches from and
squash-merges directly into that branch, never `main`. Its work surfaces only
on the parent task's single PR. It's handoff-less by design: it declares no
`handoff` status, so the hub poller auto-advances a finished worker straight
to `Done`, firing the merge into the parent branch.

```yaml
# workflows/subtask.yaml
name: subtask
description: A piece of a parent task, squash-merged into the parent's branch with no PR or review.

required_params: [task]           # every subtask must name its parent task
git:
  base_branch: task/{{task}}      # resolves to the parent task's branch
  branch: 'subtask/{{id}}'
  merge_strategy: squash

statuses:
  - { id: backlog,     owner: user,  agent: orchestrator }
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: done,        owner: user }
  - { id: canceled,    owner: user }

transitions:
  - { from: in-progress, to: done,     actions: [merge, delete_branch] }
  - { from: in-progress, to: canceled, actions: [delete_branch] }
```

Shipping these as editable files makes the default flow a thing you can
read and change rather than implicit behavior. You can rename `Todo` to
`Ready` in `statuses.yaml` without breaking anything, since the rest of the
system reasons in categories.

### The built-in `default` fallback

The `default` workflow name is a **built-in fallback**. If a project has no
`workflows/` directory at all, or a task names a `workflow:` that can't be
found, Shelbi falls back to the built-in `default`. A task with no
`workflow:` field belongs to the project default workflow (`task` on a
freshly scaffolded project), or `default` when the project configures none.

### Lifecycle: who moves a task between the default statuses

Every status declares an `owner`, and the owner of the *next* status
is who picks up the move. That makes the lifecycle of a task in the
default workflow legible at a glance:

```
┌──────────┐   ┌──────┐   ┌─────────────┐   ┌────────┐   ┌──────┐
│ Backlog  │ → │ Todo │ → │ InProgress  │ → │ Review │ → │ Done │
└──────────┘   └──────┘   └─────────────┘   └────────┘   └──────┘
   you            you      orchestrator        poller      you
   (triage)       (promote)  (auto-dispatch)   (marker)   (accept)
```

- **`Backlog → Todo` — you.** The orchestrator drops anything it
  creates from a natural-language request into `Backlog`. You decide
  what's worth doing now, and in what order. Order within `Todo` is
  your priority list; the orchestrator dispatches from the top.
- **`Todo → InProgress` — orchestrator.** As soon as a card sits in
  `Todo` (or a workspace frees up), the orchestrator routes it to a
  free workspace. This is the auto-dispatch loop. A task is **blocked**
  and skipped while any id in [`depends_on:`](#dependent-tasks-with-depends_on)
  is not yet in a `done`-category status. The Kanban shows blocked
  cards with a `🔒` badge.
- **`InProgress → Review` — hub poller.** Promoted automatically the
  moment the workspace writes its review-ready marker (see
  [next subsection](#review-marker-promotion)). No `shelbi` command
  on the workspace, no orchestrator participation.
- **`Review → Done` — you.** Accepting a diff is the moment the human
  signs off on the work. The orchestrator never crosses this edge on
  its own (except under specific multi-task loops the user has
  authorized, typically via [Zen Mode's auto-merge](/docs/concepts/zen-mode#the-high-confidence-bar)).

The pattern generalizes: **you own the intent boundaries (triage and
accept), the system handles the mechanical middle**. A custom
workflow that swaps `Review` for `QA` + `Awaiting Sign-off` doesn't
change the principle: anywhere `owner: user` sits, the orchestrator
waits for you.

### Review-marker promotion

The `active → handoff` transition (`InProgress → Review` in the
default workflow) doesn't need any active participant. The workspace
doesn't run a `shelbi` command. The orchestrator doesn't watch the
workspace's pane. Instead:

1. The workspace, when done, writes its task id into
   `<worktree>/.claude/shelbi-ready`. This instruction is part
   of the initial prompt every task ships with (see `compose_prompt`
   in `crates/shelbi-orchestrator/src/workspace.rs`).
2. The hub poller `cat`s that file on every tick (locally for hub
   workspaces, via SSH for remote ones).
3. If the file is non-empty and names a task that's in an
   `active`-category status for this workspace, the poller moves the
   task into the next `handoff` status, clears the marker, and
   appends `task=<id> <from> -> <to> reason=workspace:ready-marker` to
   the events log.

The orchestrator hears about it through its `shelbi events tail
--follow` feed and dispatches the next ready task to the now-free
workspace.

That this works without the workspace holding any Shelbi-specific state
is the point. The marker is the *entire* on-workspace protocol. See the
[workspaces concept page](/docs/concepts/workspaces#how-a-task-completes)
for the rationale and the [reason strings reference](/docs/concepts/events-log#reason-strings)
for the full set of tokens.

## A worked example: stacked feature branches

The full picture: two workflows working together to ship a multi-task
feature.

`workflows/feature-task.yaml`:

```yaml
name: feature-task
description: Stack tasks on a long-lived feature branch.

required_params: [feature]            # every task must set feature:
git:
  base_branch: feature/{{feature}}     # supplied per task

statuses:
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: user }
  - { id: done,        owner: user }

transitions:
  - from: in-progress
    to: review
    actions: [push_branch]             # push so reviewers see it; no PR
  - from: review
    to: done
    actions: [merge, delete_branch]    # squash into feature/{{feature}}
```

`workflows/feature-release.yaml`:

```yaml
name: feature-release
description: Ship a long-lived feature branch into main.

# Inherits project default base_branch: main, merge_strategy: squash —
# no git: block needed.

statuses:
  - { id: todo,        owner: agent, agent: orchestrator }
  - { id: in-progress, owner: agent, agent: developer }
  - { id: review,      owner: user }
  - { id: done,        owner: user }

transitions:
  - from: in-progress
    to: review
    actions: [push_branch, open_pr]    # PR the existing feature branch
  - from: review
    to: done
    actions: [merge, delete_branch]    # squash into main, delete branch
```

Now tasks in `feature-task` carry `feature: auth-rewrite` in their
frontmatter, so `feature-task`'s `base_branch` resolves to
`feature/auth-rewrite`, and the orchestrator cuts each task branch off
it at dispatch. A second feature's tasks carry `feature: dashboard-v2`.
Same workflow file, two concurrent stacks.

When auth-rewrite is ready to ship, create a task in `feature-release`
with `branch: feature/auth-rewrite` pre-filled. Because `branch:` is
set, the orchestrator skips the "cut a new branch" step and operates
directly on the feature branch. One PR-and-merge cycle lands the
whole feature into `main`.

## CLI reference

- `shelbi workflow list` — list every workflow in the project.
- `shelbi workflow show <name>` — print the resolved YAML.
- `shelbi workflow new <name>` — scaffold a starter file.
- `shelbi workflow edit <name>` — open in `$EDITOR`.
- `shelbi task add "Title" [--workflow <name>]` — create a card,
  optionally in a non-default workflow.
- `shelbi task move <id> --to <status>` — validates that `<status>`
  is a member of the task's workflow; errors with a list of valid
  options on mismatch.
- `shelbi task list [--workflow <name>]` — filter by workflow.

## See also

- [Workflow config](/docs/configuration/workflow) and
  [Statuses](/docs/configuration/statuses): the field-by-field YAML
  reference for the files this page explains conceptually.
- [Workspaces](/docs/concepts/workspaces) — what's on the receiving end
  of an `active`-category dispatch, and the review-marker mechanism
  workspaces use to hand off.
- [Agents](/docs/concepts/agents) — the role the `agent:` field names.
- [Orchestrator](/docs/concepts/orchestrator) — how
  `ready`-category statuses get dispatched.
- [The events log](/docs/concepts/events-log#task-transitions) — line
  shape for task transitions, including the workflow + category
  annotations.
- [Zen Mode](/docs/concepts/zen-mode) — what the action-based
  confidence bar gates, and how per-workflow `zen:` overrides work.
