Instructions for

A 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:

my-project
Orchestrator⌘O
Issues⌘I
Changelog⌘L
Workspaces
hub
alphaDeveloper⌘1
bravoDeveloper⌘2
charlieIdle
deltaIdle
devbox
echoDeveloper⌘3
foxtrotDeveloper⌘4
golfIdle
Ready for review3
Cold-start cache⌘5
you/t-011
CSV import fix⌘6
you/t-018
Nightly report⌘7
you/t-024
Queued for review2
Validate webhook payloads⌘8
you/t-023
Harden token refresh⌘9
you/t-025
Zen mode off
Filter:WorkflowsWorkspacesboard 2sSearchNew issue
BACKLOG10
Rework onboarding UX
task
Audit OSS licenses
task
Draft Q3 roadmap
task
Migrate CI to arm64
task
Sunset legacy v1 API
task
Add SSO for admins
task
Prune stale flags
task
Dedupe error reports
task
Archive S3 buckets
task
Refresh brand assets
task
TO DO9
Add API ratelimit
task
Fix mobile nav
task
Wire webhook retries
task
Split OTel spans
task
Sync i18n strings
task
Paginate search API
task
Cache user sessions
task
Add health probes
task
Debounce autosave
task
IN PROGRESS4
Deploy staging env
taskyou/t-009
Wire up OAuth flow
taskyou/t-010
Backfill order index
taskyou/t-017
Trim vendor bundle
taskyou/t-022
REVIEW5
Cold-start cache
taskyou/t-011
CSV import fix
taskyou/t-018
Nightly report
taskyou/t-024
Validate webhook payloads
taskyou/t-023
Harden token refresh
taskyou/t-025
DONE6
Migrate to PG 16
task
Ship dark mode
task
Retry dead-letters
task
Redis cache /profile
task
Add audit logging
task
Fix flaky CI tests
task
CANCELED0
h/l column k/j card ↵ open ⇧h/⇧l move ⇧k/⇧j reorder drag to move or reorder s start a assign e edit E editor n new

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-task

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

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

That 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_status pointing at nothing;
  • transition edges referencing undeclared statuses, and duplicate from -> to transitions;
  • an agent: with no matching agents/<name>/ directory (the error lists the available agents);
  • an invalid literal git.branch or git.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/agent rules 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:

CategorySemanticExample status names
backlogNot yet ready for work: triage stage.Backlog, Inbox, Triage
readyReady for whoever owns it to pick up.Todo, Ready, Next Up
activeOwner is working on it now.In Progress, Building, Designing, Drafting
handoffOne owner has finished their part; another's input is required.Review, QA, Awaiting Sign-off
doneTerminal: accepted, shipped.Done, Shipped, Released
archivedTerminal: 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 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 an issue in the next handoff status: the same code path for Review, QA, Awaiting Sign-off.
  • The activity feed renders an active → handoff move as "finished title" with a ready for review pill, 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 an agent-owned ready status 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:

  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 should be user or agent. Any other value is read as the legacy owner: <agent-name> form and migrated to owner: agent, agent: <name> with a deprecation warning. A missing owner is an error. There's no implicit default.
  3. An agent-owned status must name its agent:. A bare owner: agent fails the load, whatever the status's category.
  4. A user-owned status may still name an agent:. That declares "under Zen Mode this agent may act without me". It's how the shipped task workflow names the orchestrator on Backlog and the Review agent on Review. 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 (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.

ActionEffect
push_branchPush the issue's branch to origin. No-op if already up to date.
open_prOpen 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.
mergeMerge 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_prClose any open PR without merging.
delete_branchDelete the local + remote branch. Skipped if a workspace still has the branch checked out. That worktree replaces the branch on its next dispatch.
restackRebase 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 (typically In Progress), 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 issue'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 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's target:. They are not resolved inside status names, category names, or the rest of the transitions: shape. git.branch also 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-task requires parameter feature".
  • Every {{var}} in base_branch must be declared in the workflow's required_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:

  1. Marked blocked while waiting. Until every id in depends_on is in done, the issue shows a 🔒 badge on the board and in shelbi issue list/show, and it's left out of shelbi issue list --ready and shelbi 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 a backlog, ready, or archived status (not started, or canceled).
  2. Branch cut from the parent. When the dependent issue is dispatched, Shelbi cuts its branch off the first parent that's in an active or handoff status, using that parent issue's branch: (not the workflow's base_branch). Parents already in done are skipped, since their work is on the base branch. The child branch is still named by the workflow's git.branch template.
  3. PR base is the parent. When the dependent issue's open_pr action runs, the PR's base is the parent issue's branch, not the workflow's base_branch.

A fourth behavior fires on the parent:

  1. Auto-restack on parent merge. When the parent's merge action completes, the orchestrator iterates every issue 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, 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 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 → In Progress: 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. An issue whose depends_on: parents aren't all in done shows 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). No shelbi command on the workspace, no orchestrator participation. The transition pushes the branch and opens the PR, and the hub daemon loads the card onto a free review-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 the review → done transition, 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:

  1. 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 (see compose_prompt in crates/shelbi-orchestrator/src/workspace.rs).

  2. The hub poller cats that file on every tick (locally for hub workspaces, via SSH for remote ones).

  3. If the file is non-empty and names an issue that's in the in-progress status 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 first handoff status, else (a workflow with no handoff status, like subtask) along its merge edge straight to done. It clears the marker and appends a line like project=<p> task=<id> workflow=<name> <from> -> <to> reason=workspace:ready-marker from_category=active to_category=handoff to 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 the in-progress id (you can still change its display name in statuses.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 branch

Now 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-status STATUS / OWNER / AGENT table, in statuses.yaml order.
  • shelbi workflow new <name> [--edit]: scaffold a starter file (--edit opens 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-actions is 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. See shelbi 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.