Workflows
View as markdownA 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
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:
workflow: feature-taskWhen workflow: is absent, Shelbi checks the project config for
default_workflow::
# ~/.shelbi/projects/myapp.yaml or <repo>/.shelbi/project.yaml
default_workflow: taskThat 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:.
# ~/.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 }# ~/.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 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 whoseownerisagent. It doesn't care whether you called the columnTodoorReadyorNext Up. - The hub poller's review-marker promotion lands a task in the next
handoffstatus: the same code path forReview,QA,Awaiting Sign-off. - The activity feed renders an
active → handoffmove 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.
Owners and agents
Each status declares two things about who handles it, one required, one optional:
# 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 anagent-ownedreadystatus are eligible for auto-dispatch onto a free workspace.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 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:
- Every
idmust be declared instatuses.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 inlinename:orcategory:in a workflow file is rejected too. Identity lives in the catalog, so the two files can't drift. ownerisuseroragent. No other value parses, and a missingowneris an error too. There's no implicit default.- An
agent-owned status must name itsagent:. For thereadyandactivecategories a bareowner: agentis accepted and defaults toorchestrator/developer(with a deprecation warning); any other category withowner: agentand noagent:fails the load. - A
user-owned status may still name anagent:. That declares "under Zen Mode this agent may act without me". It's how the shipped default lets the orchestrator handleBacklogandReviewunder 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
Backlogasowner: user(you still triage), but make the merge-bearing handoff statusowner: 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
Reviewstatus (owner: agent, agent: qa) and aSignoffstatus (owner: user), as in the example above. The QA pass is automated; the final accept stays human.
The judgment policy (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.
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). |
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:
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:
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 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.
---
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 (typicallyInProgress), the orchestrator cuts a fresh branch off the workflow's resolvedbase_branch, names it by rendering the workflow's (or project's)git.branchtemplate (the shippedtaskworkflow uses{{github_user}}/{{id}}, sobuild-login-formbecomesjlong/build-login-form), and writes the name back into the task's frontmatter. When nobranchtemplate 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.
---
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:
# 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:
---
id: build-login-form
title: Build the login form
workflow: feature-task
feature: auth-rewrite # base_branch resolves to feature/auth-rewrite
------
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 thetransitions: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-taskrequires task params:feature". - Every
{{var}}inbase_branchmust be declared in the workflow'srequired_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.
---
id: build-login-form
title: Build the login form
workflow: default
------
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:
- Blocked while waiting. A task is blocked from auto-dispatch
while any id in
depends_onis not yet in adone-category status. The Kanban renders blocked cards with a🔒badge; they sit in theready-category column until their dependencies clear. This is the same gate the default workflow'sTodostatus applies to dependent tasks. See the lifecycle walkthrough above. - 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'sbranch:(not the workflow'sbase_branch). The child branch is still generated as<prefix>/<child-task-id>. - PR base is the parent. When the dependent task's
open_praction runs, the PR's base is the parent task's branch, not the workflow'sbase_branch.
A fourth behavior fires on the parent:
- Auto-restack on parent merge. When the parent's
mergeaction completes, the orchestrator iterates every task withdepends_on:containing the parent's id and runs therestackaction on each. The child branch is rebased onto the parent's target (typicallymain), and any open PR's base is updated to match. A child with multiple parents waits until every parent is in adone-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:
---
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.
# 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, 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 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 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.
# 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 intoBacklog. You decide what's worth doing now, and in what order. Order withinTodois your priority list; the orchestrator dispatches from the top.Todo → InProgress— orchestrator. As soon as a card sits inTodo(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 independs_on:is not yet in adone-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). Noshelbicommand 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).
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:
- 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 (seecompose_promptincrates/shelbi-orchestrator/src/workspace.rs). - The hub poller
cats that file on every tick (locally for hub workspaces, via SSH for remote ones). - 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 nexthandoffstatus, clears the marker, and appendstask=<id> <from> -> <to> reason=workspace:ready-markerto 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 for the rationale and the reason strings reference 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:
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:
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 branchNow 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 and Statuses: the field-by-field YAML reference for the files this page explains conceptually.
- Workspaces — what's on the receiving end
of an
active-category dispatch, and the review-marker mechanism workspaces use to hand off. - Agents — the role the
agent:field names. - Orchestrator — how
ready-category statuses get dispatched. - The events log — line shape for task transitions, including the workflow + category annotations.
- Zen Mode — what the action-based
confidence bar gates, and how per-workflow
zen:overrides work.