Workflows
View as markdownA workflow is a YAML file that declares the statuses an issue 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 issue 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 issues 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, archived. Categories
are the vocabulary the rest of the system reasons in.
The board renders one column per status, in statuses.yaml order: six
for the shipped catalog (Backlog, Todo, In Progress, Review, Done,
Canceled). Each header is colored by its status's category, and an empty
column folds to a narrow strip, so Canceled usually shows as one:
Schema and parser live in crates/shelbi-core/src/workflow.rs.
Workflow files live at
~/.shelbi/projects/<project>/workflows/<name>.yaml (or
<repo>/.shelbi/workflows/<name>.yaml for an
in-repo project).
You can also manage them from Settings ▸ Workflows in the TUI and the desktop app: list the workflows and the status catalog, create, duplicate, delete, or set the default workflow, open a file's YAML, add, rename, recategorize, and reorder statuses, and edit a workflow in a form or a graph builder. Every change is saved to the same YAML files this page describes.
Choosing the default workflow
An issue 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 issues 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 uses the workflow named default.
That name is an alias: on a project that has task.yaml but no
default.yaml, default (in default_workflow: or an issue's
workflow: default) resolves to task. The in-memory built-in default
is used only when neither file exists.
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 }
- { id: canceled, name: Canceled, category: archived }# ~/.shelbi/projects/<project>/workflows/example.yaml
# An illustrative custom workflow (the shipped task.yaml is shown below).
name: example
description: A one-track flow with no git side-effects.
# 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 issue 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 the project's
# base_branch unchanged.
# git:
# base_branch: main
# merge_strategy: squash # accepted, but `merge` uses the project's
# Optional. Edge-by-edge action declarations. Each entry says what side
# effects to run when an issue 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: [push_branch, merge]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, and duplicate status ids; initial_statuspointing at nothing;- transition edges referencing undeclared statuses, and duplicate
from -> totransitions; - an
agent:with no matchingagents/<name>/directory (the error lists the available agents); - an invalid literal
git.branchorgit.branch_prefix(a templated name is checked after it's rendered); - a missing
statuses.yaml, which is a hard error for every workflow; - the
owner/agentrules covered below.
The error message names the workflow and the bad field. A by-name load
(shelbi workflow show, dispatch) fails hard on a bad file. shelbi workflow list and the board instead skip a malformed workflow file
with a warning and keep loading the rest. A catalog with more than one
backlog, ready, or handoff status only warns: generic code keys off
the first one.
Status categories
Every status, default or custom, declares one of six 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. | In Progress, 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 |
archived | Terminal: closed without shipping. | Canceled, Abandoned, Won't Do |
The category set is closed at six. A workflow can't introduce a
seventh. 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 an issue in the next
handoffstatus: the same code path forReview,QA,Awaiting Sign-off. - The activity feed renders an
active → handoffmove as "finished title" with aready for reviewpill, 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 }signoff isn't in the shipped catalog, so this example needs
- { id: signoff, name: Signoff, category: handoff } added to
statuses.yaml first (above done, since columns follow catalog order).
Shelbi then warns that the catalog has two handoff statuses: generic
code (the category-to-column mapping, Zen's probe) keys off the first,
review, so signoff is reached only by its exact id.
owner: user | agent declares who the next mover is:
agent: the orchestrator is the next mover. Issues sitting in anagent-ownedreadystatus are eligible for auto-dispatch onto a free workspace.user: the orchestrator does not act. The issue 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 issue 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, and it's required whenever owner: agent. 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. ownershould beuseroragent. Any other value is read as the legacyowner: <agent-name>form and migrated toowner: agent, agent: <name>with a deprecation warning. A missingowneris an error. There's no implicit default.- An
agent-owned status must name itsagent:. A bareowner: agentfails the load, whatever the status's category. - A
user-owned status may still name anagent:. That declares "under Zen Mode this agent may act without me". It's how the shippedtaskworkflow names the orchestrator onBacklogand the Review agent onReview. 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 prose, in the project's
zenmode.md, 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 issue's branch to origin. No-op if already up to date. |
open_pr | Open a PR. Base is transition.target if set, else the parent issue's branch when depends_on names a parent that isn't yet done, else the workflow's resolved base_branch. No-op if a PR is already open. |
merge | Merge the issue's branch with the project's git.merge_strategy (a workflow-level merge_strategy is currently ignored). Via gh pr merge when a PR is open; via local git merge on the hub otherwise. Supported strategies: squash, merge; rebase passes validation but merge refuses it. |
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 issue's branch onto the parent's merge target (the base the parent just landed on, typically main), not the parent's branch. Fires automatically on dependent issues when a parent's merge completes (see depends_on). |
All actions are idempotent and orchestrated from the hub; workspaces
never invoke them. push_branch and open_pr run in the issue's worktree
on its machine (over SSH for a remote one), since that's where the branch
lives. merge, close_pr, and delete_branch run on the hub. To run one
by hand, use shelbi action.
The shipped task workflow's forward transitions (it also declares two
cancel edges, shown below):
transitions:
- from: in-progress
to: review
actions: [push_branch, open_pr]
- from: review
to: done
actions: [push_branch, merge]A transition can do more than git actions: run: (shell commands run on
entry), ready: (a readiness probe, with ready_timeout:, default 90s),
and url: (the address the review interface opens). A workflow can also
carry a top-level review: serve recipe and a zen: override. See
run, ready, and teardown,
review, and
zen in the field reference.
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: may use {{var}} placeholders, resolved from the issue's
frontmatter like the git: block (see Parameterization).
It does not affect where new issue 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 issue carries an optional branch: field in its frontmatter: the
name of the git branch the issue 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: task
# branch: ← omitted → orchestrator generates jlong/build-login-form on dispatch
---- Omitted at creation. When the issue moves into its first
active-category status (typicallyIn Progress), 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 issue'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 issues 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 issue does.
Parameterization
A git: block can use {{var}} placeholders that resolve from the
issue's frontmatter when the issue is dispatched or merged. 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 issue must set feature:
git:
base_branch: feature/{{feature}} # resolved per issue
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 issues, 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 resolve in
git:field values and in a transition'starget:. They are not resolved inside status names, category names, or the rest of thetransitions:shape.git.branchalso gets the built-ins{{id}}and{{github_user}}. - A missing key produces one clear error per workflow listing every
unresolved placeholder, so a single edit to the issue's frontmatter
fixes them all: "workflow
feature-taskrequires parameterfeature". - 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 issue carries is caught before dispatch, not after.
Dependent tasks with depends_on
Issues declare a depends_on: list in their frontmatter to express a
parent/child relationship. This is how Graphite-style stacked PRs are
modeled: issue B builds on top of issue A's branch, which builds on
main.
---
id: build-login-form
title: Build the login form
workflow: task
------
id: build-dashboard
title: Build the dashboard (uses login form)
workflow: task
depends_on: [build-login-form] # branch cut off build-login-form's branch
---Three behaviors fire when depends_on: is set:
- Marked blocked while waiting. Until every id in
depends_onis indone, the issue shows a🔒badge on the board and inshelbi issue list/show, and it's left out ofshelbi issue list --readyandshelbi zen scan. That badge is advisory: nothing hard-gates dispatch on it, so leave an issue you don't want started in Backlog. What dispatch does enforce is the branch cut below: it refuses while a parent is still in abacklog,ready, orarchivedstatus (not started, or canceled). - Branch cut from the parent. When the dependent issue is
dispatched, Shelbi cuts its branch off the first parent that's in an
activeorhandoffstatus, using that parent issue'sbranch:(not the workflow'sbase_branch). Parents already indoneare skipped, since their work is on the base branch. The child branch is still named by the workflow'sgit.branchtemplate. - PR base is the parent. When the dependent issue's
open_praction runs, the PR's base is the parent issue'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 issue 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, self-references, and unknown ids in depends_on: are rejected
when the issue is created or its dependencies are edited
(shelbi issue add, shelbi issue depends), with an error naming the
cycle path (dependency cycle: a → b → a). An issue whose parent was
canceled stays 🔒 blocked, and its branch cut is refused, until you
remove that parent from its depends_on:.
Per-task workflow assignment
Issues 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 # a status id from the assigned workflow
---If workflow: is absent, the issue uses default_workflow: from project
config. If the project does not declare one, Shelbi falls back to default
(which resolves to task when the project has task.yaml but no
default.yaml).
If an explicit issue 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
issue silently.
An issue is bound to its workflow at creation. To switch it to another
one, run shelbi issue edit <id> --workflow <name> or change it in the
issue editor; the target must name an existing workflows/<name>.yaml.
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: an issue 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: [push_branch, merge] }
- { 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]. The In Progress → Review transition pushes the
branch and opens the PR, and the card appears in the sidebar under
Queued for Review. The hub daemon's review auto-loader (not the
orchestrator) then loads it onto a free review-tagged workspace, or
you load it yourself from the Queued for Review list, and boots the
Review agent, which stands up the running app so
you can click through the branch. Once it's served, the card shows under
Ready for Review. Moving the card to Done runs the review → done
transition, which pushes and merges. 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 issue, done on its own
branch with no PR and no review. A subtask names its parent issue's id in
its task: frontmatter, set with
shelbi issue edit <id> --param task=<parent-id>, and the templated
base_branch: task/{{task}} renders as task/<parent-id>. The subtask branches from and squash-merges
into that branch, never main, so its work surfaces only on the parent's
PR. That only matches the parent's actual branch when the parent's branch
is named task/<parent-id>. The shipped task workflow names branches
<github-user>/<id>, so either set the parent's branch: to
task/<parent-id> when you create it, or edit base_branch in
subtask.yaml to match your naming. 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 issue
git:
base_branch: task/{{task}} # renders as task/<parent-id>
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: [push_branch, merge] }
- { 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 an issue names a workflow: that can't be
found, Shelbi falls back to the built-in default. An issue with no
workflow: field belongs to the project default workflow (task on a
freshly scaffolded project), or default when the project configures none.
The name default is also an alias: when workflows/default.yaml doesn't
exist but task.yaml does, default resolves to task.
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 an issue in the
default workflow legible at a glance:
┌──────────┐ ┌──────┐ ┌─────────────┐ ┌────────┐ ┌──────┐
│ Backlog │ → │ Todo │ → │ In Progress │ → │ 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 → In Progress: 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. An issue whosedepends_on:parents aren't all indoneshows a🔒badge, but the badge doesn't hard-gate dispatch, so keep issues you don't want started in Backlog.In Progress → Review: hub poller. Promoted automatically the moment the workspace writes its review-ready marker (see next subsection). Noshelbicommand on the workspace, no orchestrator participation. The transition pushes the branch and opens the PR, and the hub daemon loads the card onto a freereview-tagged workspace if you have one.Review → Done: you. Accepting a diff is the moment the human signs off on the work. Moving the card to Done runs thereview → donetransition, which merges it. The orchestrator never crosses this edge on its own (except under specific multi-issue 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 (In Progress → Review in the
task 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 issue id into
<worktree>/.shelbi/shelbi-ready. This instruction is part of the initial prompt every issue 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 an issue that's in the
in-progressstatus and assigned to this workspace, the poller moves the issue forward, running that edge's declared actions: into a declared agent-owned active gate if the workflow routes through one, else into the workflow's firsthandoffstatus, else (a workflow with no handoff status, likesubtask) along its merge edge straight todone. It clears the marker and appends a line likeproject=<p> task=<id> workflow=<name> <from> -> <to> reason=workspace:ready-marker from_category=active to_category=handoffto the events log.The marker only fires from the status id
in-progress. A custom workflow that renames its working status to another id won't be promoted by the marker, so keep thein-progressid (you can still change its display name instatuses.yaml).
The orchestrator hears about it through its shelbi events tail --follow feed and dispatches the next ready issue 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-issue feature.
workflows/feature-task.yaml:
name: feature-task
description: Stack issues on a long-lived feature branch.
required_params: [feature] # every issue must set feature:
git:
base_branch: feature/{{feature}} # supplied per issue
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 issues 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 issue branch off
it at dispatch. A second feature's issues carry feature: dashboard-v2.
Same workflow file, two concurrent stacks.
When auth-rewrite is ready to ship, create an issue 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 workflow's per-statusSTATUS / OWNER / AGENTtable, instatuses.yamlorder.shelbi workflow new <name> [--edit]: scaffold a starter file (--editopens it in$EDITOR).shelbi workflow edit <name>: open in$EDITOR.shelbi issue add "Title" [--workflow <name>]: create a card, optionally in a non-default workflow.shelbi issue edit <id> --workflow <name>: move an issue to another workflow.shelbi issue move <id> --to <status>: validates that<status>is a member of the issue's workflow; errors with a list of valid options on mismatch.--skip-transition-actionsis the recovery escape hatch: it moves the card without running the edge's actions (for git work already done by hand).shelbi issue list [--workflow <name>]: filter by workflow.shelbi action <push-branch|open-pr|merge|close-pr|delete-branch|restack|apply-transition> <id>: run a transition action primitive by hand. Seeshelbi action.
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 issue transitions, including the workflow + category annotations.
- Zen Mode: what the action-based
confidence bar gates, and how per-workflow
zen:overrides work.