YAML catalog
Stageflow uses a pipeline-owned catalog: each pipeline file lists stages as object entries with uses: (external YAML) or an inline body. Tasks are separate *.task.yaml files. A repo-root stageflow.yaml manifest declares which directories the operator console browses.
Canonical fixtures: tests/fixtures/pipelines/, tests/fixtures/stages/, tests/fixtures/tasks/.
Layout
my-project/
stageflow.yaml
pipelines/
hello.pipeline.yaml # inline or uses: stage entries
tasks/
hello.task.yaml
.stageflow/ # runtime state at git root
Flat layout — pipeline and task files may also live at the repo root (e.g. hello.pipeline.yaml, my-task.task.yaml) beside stageflow.yaml; validation and CLI accept any filesystem path. This repo uses a flat root for some pipelines under tests/fixtures/.
Runnable examples live under examples/. This repo's manifest is stageflow.yaml (examples only; tests/fixtures excluded from browse).
Filename patterns
| Kind | Pattern | Example |
|---|---|---|
| Pipeline | *.pipeline.yaml |
hello.pipeline.yaml |
| Task | *.task.yaml |
my-task.task.yaml |
| Stage (external) | any *.yaml beside pipeline or under shared pool |
research.yaml, ../stages/clarify.yaml |
CLI --pipeline and --task require filesystem paths — there is no bare-id fallback.
Pipelines (*.pipeline.yaml)
Required top-level fields:
| Field | Type | Description |
|---|---|---|
id |
string | Pipeline identifier (should match filename stem) |
stages |
array | Non-empty list of object stage entries |
Bare string stage refs are rejected.
Stage entries
Each stage is an object with one of:
| Form | Fields | Use when |
|---|---|---|
| External | uses: <path> |
Stage body lives in another YAML file |
| Inline | system_prompt, model, … |
Single-file pipeline |
id may be omitted when it is inferable from the uses: basename (*.yaml or *.stage.yaml).
Wiring (any entry, including uses:): needs, fork, clonable, clone_cap, skill, completion, recovery, feedback_loop, replay_safe.
Body (inline entry or external stage file): system_prompt, model, gate_kinds, pre_emit_checks, payload_schema, clone_input_schema, clone_actions, timeout_ms. The JSON Schema subset for payload_schema and clone_input_schema is in Envelopes. pre_emit_checks is an in-session gate emit_stage_envelope enforces on success emits — see Envelopes — pre_emit_checks; it is distinct from the pipeline-wiring completion field below. Optional parent clone_actions is a non-empty list of skip | once | fanout; omit the field to keep all three. See Envelopes — clonable successors. Optional timeout_ms is a positive integer wall-clock budget for the stage attempt in milliseconds (default 3600000 / 60 minutes when omitted).
uses: plus any body key except skill is rejected (pipeline.stage_uses_inline_conflict). skill may sit on the uses: wrapper.
completion and recovery are pipeline-stage execution policy. They may sit beside
uses: because a reusable stage can require different proof or recovery behavior in
different pipelines.
Completion and recovery
completion declares the checks Stageflow runs after an agent emits a successful
envelope. It is optional; without it, normal envelope validation remains the stage's
success condition.
completion:
mode: all
checks:
- id: tests
type: command
run: npm test
| Check type | Required fields | Optional fields |
|---|---|---|
command |
id, run |
cwd, timeout_ms |
artifact |
id, path |
nonempty |
checklist |
id, items |
— |
payload_schema |
id |
— |
gate |
id, kind |
— |
checkout_changes |
id |
path_fields |
mode is currently all, so every check must pass. Check IDs are unique within
the stage. artifact.path is relative to the stage attempt's artifact directory.
gate.kind must also appear in the reusable stage's gate_kinds. Each
checkout_changes.path_fields entry must name a required array-of-strings field in
the reusable stage's payload_schema.
recovery is optional and applies only after a completion verification failure:
| Mode | Required fields | Behavior |
|---|---|---|
repair |
max_attempts, retry_safety: idempotent, include_failed_checks |
Stageflow starts fresh attempts until the limit, carrying failed-check evidence when configured. |
manual |
retry_safety |
An operator explicitly starts a new attempt with optional guidance or stops recovery for that run. |
Use manual for side-effecting work such as publishing or payments. See
Verified Stage Execution for evidence semantics,
recovery behavior, and examples.
uses: paths are relative to the pipeline file's directory.
Linear chain with external stages:
id: linear-explicit
stages:
- id: clarify
uses: ../stages/clarify.yaml
- id: design-doc
uses: ../stages/design-doc.yaml
needs: clarify
- id: implementation-plan
uses: ../stages/implementation-plan.yaml
needs: design-doc
See tests/fixtures/pipelines/linear-explicit.pipeline.yaml.
Inline single stage:
id: hello
stages:
- id: research
system_prompt: Summarize the task goal.
model: anthropic/claude-sonnet-4-5
Parallel fan-out: multiple stages with the same needs (siblings):
stages:
- id: clarify
uses: ../stages/clarify.yaml
- id: design-doc
uses: ../stages/design-doc.yaml
needs: clarify
- id: implementation-plan
uses: ../stages/implementation-plan.yaml
needs: clarify
See tests/fixtures/pipelines/parallel-after-clarify.pipeline.yaml.
needs is either a single parent stage id (string) or an array of at least two parents. Parallel fan-out is multiple children with the same parent. Keyed generic fan-in is one child with a needs array — see Generic fan-in. Clone-list joins still use a single catalog parent id — see Clonable successors.
Generic fan-in {#generic-fan-in}
A stage may wait for two or more catalog parents. needs takes one of two forms:
| Form | Shape | When |
|---|---|---|
| Scalar | needs: <stage-id> |
One parent. The accepted terminal is succeeded only (legacy form). |
| Array | needs: [ … ] with length ≥ 2 |
Keyed generic fan-in. A one-item array is rejected. |
Array items may be mixed. A string id defaults to on: [succeeded]. { id, on } declares a non-empty unique subset of succeeded | failed | skipped. Duplicate ids, unknown keys, unknown parents, empty on, and cycles are rejected.
id: diamond-fan-in
stages:
- id: clarify
uses: ../stages/clarify.yaml
- id: research
uses: ../stages/research.yaml
needs: clarify
- id: validation
uses: ../stages/validation.yaml
needs: clarify
- id: synthesize
uses: ../stages/synthesize.yaml
needs:
- research
- validation
Structured on sets (accepted failure or skip):
- id: synthesize
uses: ../stages/synthesize.yaml
needs:
- id: research
on: [succeeded, failed, skipped]
- id: validation
on: [succeeded]
The join starts only after every declared parent (or every current clone instance of a clonable parent) is terminal in that parent's accepted set. Join input is priorEnvelopesByStage, keyed in YAML declaration order. priorEnvelope is null. Do not reuse clone-list priorEnvelopes — that field stays for clone-list joins. A clonable parent under generic fan-in maps to one key whose value is that parent's clone-list-ordered envelope array (or [] when a skip of the definition is accepted). See Envelopes. Walkthrough: examples/generic-fan-in/.
A parent whose observed terminal is not in that edge's on set skips only incompatible paths. Independent siblings and joins that accept the observed state continue. Accepted failed or skipped parents stay visibly terminal and do not independently fail the run.
Fixtures:
diamond-fan-in.pipeline.yaml— static diamond, stringneedsarraydiamond-fan-in-accepted.pipeline.yaml— structuredonincluding failed and skippeddiamond-fan-in-clone.pipeline.yaml— clonable parent plus named sibling join
Runtime coverage: tests/runtime.genericFanIn.schedule.test.ts, tests/runtime.genericFanIn.retry.test.ts, tests/runtime.envelopeRouting.test.ts, tests/runstore.trackProjection.test.ts.
Pipeline fragments (include:)
At the pipeline top level (not inside a stage entry), merge stage lists from fragment files:
id: include-merge
include:
- local: ./fragments/gates.yaml
stages:
- id: finish
uses: ./finish.yaml
needs: gate
Fragment files contain a stages: array (same entry shapes as the parent pipeline). Paths in local: are relative to the pipeline file's directory.
Nested include: is allowed. Cycles and duplicate stage ids across files are rejected. Includes merge before the declaring file's own stages.
Fixture: tests/fixtures/pipeline-owned/include-merge/main.pipeline.yaml.
Fork pipelines
A deciding stage may declare a fork object to require a runtime choice among its immediate successors. The stage must emit fork_choice in its envelope (see Envelopes). Use object-form stage entries (id: + optional uses: + fork:) — bare string stage refs cannot carry fork.
| Field | Required | Description |
|---|---|---|
select |
yes | one — exactly one immediate successor must be named in fork_choice. subset — one or more successors (including all, some, or none when allow_none is set). |
allow_none |
no | Boolean, default false. Only usable with select: subset. When true, an empty fork_choice: [] is valid and all immediate successors are skipped. |
fork accepts only select and allow_none. Catalog validation does not reject select: one together with allow_none: true; emit still requires exactly one choice — empty fork_choice fails even if allow_none is set.
fork_choice names only non-clonable immediate successors. When every child is clonable, fork_choice is not required.
Children list needs: <parent>. A stage with multiple children and no fork field is parallel fan-out — every successor runs. A stage with fork requires the completing agent to name which successors run via fork_choice. Requiring fork_choice from a plain fan-out stage would break existing pipelines; omitting it from a fork stage fails emit validation.
Unchosen branches are marked skipped, including all downstream descendants of the unchosen stage — not only the immediate successor — unless a multi-parent join lists skipped in that parent's on set. That join waits for its other parents instead of cascade-skipping. Non-accepting paths still cascade. In fork-route-cascade.pipeline.yaml, when clarify emits fork_choice: ["design-doc"], both implementation-plan and join-doc are skipped because join-doc depends on the unchosen branch (scalar needs, so skipped is not accepted).
Skipped stages appear on every observable surface with the same skipped status used when a parent fails: operator console spatial map / run detail, CLI stage output, MCP get_run, and JSON run records. Unchosen fork branches are not failures; see CI / headless for exit-code behavior.
fork on a stage with no immediate successors (a DAG leaf) fails validation (pipeline.dag_error). Pipelines without fork are unaffected.
id: fork-demo
stages:
- id: decide
uses: ./decide.yaml
fork:
select: one
- id: branch-a
uses: ./branch-a.yaml
needs: decide
- id: branch-b
uses: ./branch-b.yaml
needs: decide
Fixtures:
fork-one-of-two.pipeline.yaml— exclusiveselect: onefork-route-cascade.pipeline.yaml— cascade skip through descendantsfork-route-subset.pipeline.yaml—select: subset, multiple successors allowedfork-route-allow-none.pipeline.yaml—allow_none: true, empty choice valid
Walkthrough: examples/conditional-fork/.
Clonable successors {#clonable-successors}
A successor object entry may set clonable: true. The completing predecessor must then emit clone_forks for that successor (see Envelopes). Optional clone_cap is an integer; omit the field to take the default 5. When clone_cap is set, it must be an integer ≥ 2 — setting 1 is a catalog validation error. Bare string refs cannot carry clonable. Over-cap fails the predecessor. clonable: true on a DAG leaf fails catalog validation — a clonable successor must have at least one child (typically a join).
id: clonable-demo
stages:
- id: detect-changes
uses: ./detect-changes.yaml
- id: author-diagrams
uses: ./author-diagrams.yaml
needs: detect-changes
clonable: true
clone_cap: 5
- id: collect
uses: ./collect.yaml
needs: author-diagrams
A clone may skip, run once, or fan out its own successor only when that successor is also clonable. See clonable-nested-gate.pipeline.yaml and examples/clonable-fanout/. v1 does not support two clones both fanning out the same successor.
A clonable successor is not selected via fork_choice. clone_forks is the only include/skip/N control for that successor. fork_choice ids are non-clonable immediate successors; when every child is clonable, fork_choice is not required. Named siblings still use fork_choice when the parent has fork. See clone-fanout-mix.pipeline.yaml (fork.select: subset plus clonable design-doc and named implementation-plan).
Instance ids {#clonable-instance-ids}
Run-once keeps the catalog id. Fan-out mints {catalogId}~{n} with 1-based n in the predecessor's clone-list order. YAML needs stays the catalog id. Instance ids must not contain /, \, or ... The operator console labels clones definition · N (see Operator console); disk paths and API keys stay the raw instance id.
A clone-list join still names one catalog parent id. Join requires every clone to succeed in both modes. When the join runs, priorEnvelopes are success-only (0.7; 0.5 included failures). Sequential also skips remaining clones on first failure; parallel lets sibling clones finish. Details: envelopes. That list field is not used for generic fan-in — a needs array receives priorEnvelopesByStage instead.
Fixtures:
clonable-default-cap.pipeline.yaml—clonable: truewith default cap 5clone-fanout-join.pipeline.yaml— fan-out then joinclone-fanout-mix.pipeline.yaml— mix with named sibling + forkclonable-nested-gate.pipeline.yaml— clone toward a non-clonable collectclonable-nested-fanout.pipeline.yaml— clonable successor of a clone, then a non-clonable join
Walkthrough: examples/clonable-fanout/.
Rewire of examples/archify-on-pr is deferred; that example remains a single author-diagrams session until a later change.
Feedback loops {#feedback-loops}
A stage may declare a source-owned feedback_loop policy so that, on success, it can either advance downstream (continue) or send work back to an earlier ancestor (send_back). Feedback loops do not add reverse needs edges — the catalog DAG stays forward-only; replay is a runtime schedule over the existing route.
id: feedback-loop
stages:
- id: plan
uses: ./plan.yaml
- id: implement
uses: ./implement.yaml
needs: plan
- id: review
uses: ./review.yaml
needs: implement
feedback_loop:
target: implement
max_replays: 2
on_max_replays: require_continue
replay_session: resume
- id: submit
uses: ./submit.yaml
needs: review
replay_safe: false
| Field | Required | Description |
|---|---|---|
target |
yes | Stage id of an earlier ancestor of the source (via needs). |
max_replays |
yes | Positive integer — how many accepted send_back replays the source may take before the max-replays policy applies. |
on_max_replays |
yes | require_continue — a further send_back is rejected (emit fails with exceeded max_replays); the source must emit continue instead. wait_for_human — park for an operator decision (extend / continue / abandon). |
replay_session |
yes | resume — reopen the prior agent session (feedback_resume). new_session — start a fresh session for the replayed stage. |
Optional on any stage entry: replay_safe (boolean). Omitted means safe — the stage may appear on a feedback replay route. Set replay_safe: false on stages that must not be replayed (for example a one-shot submit). Catalog validation rejects a loop whose target→source route includes any replay_safe: false stage.
Validation rules
- The policy lives on the source stage (the one that emits
feedback_loopin its envelope). The target must already be declared and must be an ancestor — not the source itself, not a sibling, not a descendant. - Neither the source nor the target may be
clonable: true. - Unknown
feedback_loopkeys are rejected. - Persistent fork parents are valid targets (send-back can re-enter a fork parent). Clonable successors are not.
Runtime behavior (session, forks, artifacts)
- On
send_back, Stageflow replays the inclusive route from the target through the source (forward order). Downstream of the source stays held until the loop continues or is abandoned. - Replayed stages receive a Feedback Loop Context block in the agent prompt (
loop_id,replay_id, source envelope, remaining replays, route ids, optional prior attempt / active fork generation). See Envelopes — Feedback loops. replay_session: resumemaps to session modefeedback_resume(resume token from the prior attempt).new_sessionmaps tonew_session.- When the replay route re-fans out a clonable successor, the prior clone cohort is superseded and a new fork generation mints fresh instance ids (
{catalogId}~{n}). Prior clone artifacts remain under their attempt paths; the active generation is the one named in Feedback Loop Context. - Artifacts stay attempt-scoped. Downstream stages still consume the latest accepted envelopes on the active route; the send-back feedback itself is the source envelope carried in Feedback Loop Context.
Operator / host-down decisions when on_max_replays: wait_for_human: CLI sf runs feedback-decide, MCP decide_feedback_loop, or POST /api/runs/:runId/stages/:stageId/feedback-decision.
Fixtures: feedback-loop.pipeline.yaml, feedback-loop-wait-human.pipeline.yaml, feedback-loop-clone-fanout.pipeline.yaml.
Walkthrough: examples/feedback-loop/.
Skill binding {#skill-binding}
Bind a Pi skill to a stage on the pipeline stage entry (alongside uses: or inline body). The loader also accepts skill: in external stage files; a pipeline-entry skill overrides the file value on merge. Prefer the pipeline entry.
stages:
- id: author-diagrams
uses: ./author-diagrams.yaml
needs: detect-changes
skill: archify
| Behavior | Detail |
|---|---|
| Resolution | Looks up .pi/skills/<name>/SKILL.md under the operator checkout (--operator-cwd / STAGEFLOW_OPERATOR_CWD) and the Pi agent skills dir |
| Startup | Stage fails before the agent session if the skill is not installed |
| Agent prompt | Skill instructions are injected for the stage attempt |
Install skills before sf run in CI:
sf skills install --from-zip <url> --skill-name archify
Walkthrough: examples/archify-on-pr/ — GHA provisions Archify, agents author JSON specs only; shell steps run deliver outside the agent.
External stage files
Stage YAML (referenced via uses:) requires:
| Field | Description |
|---|---|
id |
Must match the pipeline entry id and the filename stem (sf validate) |
system_prompt |
Agent instructions |
model |
Provider/model string |
Optional body fields on the file (not on the uses: wrapper): gate_kinds, pre_emit_checks, payload_schema, clone_input_schema, clone_actions, timeout_ms — see Envelopes and Envelopes — pre_emit_checks. The loader accepts skill: here; prefer binding it on the pipeline entry (see Skill binding). clone_input_schema is the successor assignment contract (not the child's later output payload_schema). clone_actions on a parent restricts emit clone actions; omit keeps skip, once, and fanout. timeout_ms is an optional positive integer millisecond attempt budget (default 60 minutes).
Shared pool example: tests/fixtures/stages/plan-review.yaml.
Tasks (*.task.yaml)
| Field | Required | Description |
|---|---|---|
id |
yes | Task identifier |
goal |
yes | What the run should accomplish |
context |
no | Background for agents |
constraints |
no | Boundaries |
checkout |
no | Relative or absolute path to working tree |
See tests/fixtures/tasks/sample.task.yaml.
Manifest (stageflow.yaml)
Declares catalog roots for sf validate (manifest-all) and operator-console browse.
version: 1
catalog:
pipelines:
- examples/hello-world
- examples/plan-review
tasks:
- examples/hello-world
- examples/plan-review
patterns:
pipeline: "*.pipeline.yaml"
task: "*.task.yaml"
exclude:
- tests/fixtures
exclude: paths omitted from console browse (fixtures may still be loaded by explicit CLI path in tests).patterns: glob for directory scans (defaults shown above).
Scaffold a new project: sf init creates stageflow.yaml, pipelines/ (with an inline stage in hello.pipeline.yaml), and tasks/ — not a global stages/ pool.
Validation
sf validate --strict # manifest-all: pipelines, stages, and tasks from git root
sf validate --pipeline path/to/x.pipeline.yaml --strict # that pipeline and its stages
sf validate --task path/to/x.task.yaml --strict # that task
Validation checks pipeline shape, uses: resolution, DAG (needs, cycles), stage file shape, and task shape. It does not verify provider credentials or checkout paths.
CLI run
sf run \
--pipeline examples/hello-world/hello.pipeline.yaml \
--task examples/hello-world/my-task.task.yaml
Run state is stored under <git-root>/.stageflow/ regardless of which subdirectory you start sf ui from.