Skip to content

Workflows Reference

New to workflows? Start with the Building Workflows guide — a progressive, tutorial-style walkthrough from minimal to advanced.

Authoritative reference for the agents-fleet workflow system: file format, bundled workflows, custom-workflow authoring, runner semantics, and per-command override via crews. Source of truth: src/skills/types.ts at HEAD (v0.36.3).


What a workflow is

A workflow is a declarative, named description of a multi-step process. It lives in a *.workflow.md file with YAML frontmatter (an ordered list of stages) plus a Markdown body. Loaded into SkillRegistry as a Workflow artifact (src/skills/types.ts:139-143) and resolved by name via registry.getWorkflow(name).

Workflows are passive data — they do not execute themselves. Two kinds of clients consume them:

  1. Workflow-driven slash commands (/feature, /code-review, /init, /research) act as thin runners over a resolved workflow's stages[]. For /feature, each stage's prompt template even lives in a sibling file (<workflow-name>/<stage-name>.prompt.md) — so editing the workflow or its sibling prompts changes execution without touching the command handler.
  2. Crews running freeform inject the workflow body as an <active-workflow> block in the coordinator's system prompt (see docs/composition.md §Workflows).

Stages can be sequential (after: clause) or parallel (parallel: true). Runners that walk stages enforce these semantics; freeform consumers do not.

Heads-up. All four workflow-driven commands (/feature, /code-review, /research, /init) now share a single generic stage runner (src/skills/workflowRunner.ts:194). Each picks a mode appropriate to its historical contract — /feature uses per-stage-prompt, /code-review uses roster, /research and /init use sequential. See Generic stage runner and Runner semantics.


Workflow file format

File location & extension

Workflows live in three tiers, each searched from a tier-specific root and loaded by loadWorkflowsFromDir (src/skills/parser.ts:860-878):

TierRootNotes
Bundledsrc/skills/bundled/workflows/ (dev) → dist/skills/bundled/workflows/ (packaged)Shipped with the CLI.
User~/.fleet/workflows/Personal, shared across projects.
Project<cwd>/.fleet/workflows/Committed to the repo if you want team-wide.

Files must end in *.workflow.md. The loader skips backups (*.v<N>.workflow.md) and shadow files (*.shadow.workflow.md).

Tier precedence: project > user > bundled (latest wins via upsertArtifact in src/skills/SkillRegistry.ts:1096-1110). A project-tier code-review.workflow.md silently shadows the bundled one by name.

Frontmatter schema (YAML)

The Workflow interface (src/skills/types.ts:139-143) extends BaseArtifact (src/skills/types.ts:87-97):

FieldTypeRequiredDescription
namestringUnique identifier. Must satisfy ArtifactValidator.validateName (alphanumeric + _/-, leading letter/digit). Resolved by registry.getWorkflow(name).
descriptionstringrecommendedOne-line summary. Surfaced by /skills. Empty string allowed by the parser but considered poor practice.
stagesWorkflowStage[]optionalOrdered list of stages. Empty list / omitted ⇒ freeform — runners that require stages will refuse to run, but freeform crew consumption is fine.
versionnumber | stringoptionalAuthor hint; auto-bumped on save by SkillRegistry.saveWorkflow.
commandstringoptionalWhen set, the registry synthesises a slash command of this name that runs the workflow via runWorkflowStages. Subject to security clamps (project-tier requires --trust-project-workflows) and name validation (reserved names rejected). See Authoring auto-discovered commands.
command_aliasesstring[]optionalExtra names the synthesised command answers to (e.g. [rp, ship] makes /rp and /ship work). Same validation rules as command. commandAliases also accepted.
command_usagestringoptionalFree-form usage line printed in /help and on argument-validation failure. commandUsage also accepted.
argsWorkflowArgSpec[]optionalPositional argument spec for the synthesised command. Each entry is { name, required?, description? } (src/skills/types.ts:142-149). No pattern: regex in v1 — name + required + description only.
completionWorkflowCompletionSpecoptionalDeterministic unattended completion contract. See Workflow completion metadata.

The body of the file (everything after the closing ---) becomes Workflow.body and is also injected into the coordinator's prompt in freeform mode. Its SHA-256 is stored on bodySha256 for intelligence tracking.

WorkflowStage schema

Defined at src/skills/types.ts:119-136 and parsed at src/skills/parser.ts:591-630:

FieldTypeRequiredDescription
namestringStage label, e.g. brainstorm, review. Used by after: references, by /feature's stage-prompt resolver, and as the header text in console output.
agentsstring[]optionalAgent / role names this stage runs. Inline array [a, b, c] or block list both work. /code-review and /research read this list to build their roster.
parallelbooleanoptionaltrue ⇒ runners spawn all agents in agents[] simultaneously. Default false (sequential).
after'all' | string[]optionalPredecessor stages. The literal string 'all' (a runner-specific shorthand) or an explicit list. Bare-string values are coerced to a single-element array (parser.ts:603-606).
gatesstring[]optionalReserved for approval-gate metadata. Parsed but currently uninspected by any runner. Safe to author for forward-compat; don't expect enforcement today.
artifactstringoptionalFilename (relative to the plan dir) the stage is expected to produce. When set, the runner verifies the file exists after the stage runs and retries once with an explicit create instruction if missing. Currently honored by /feature.
workersRequirednumberoptionalMinimum number of new parallel workers the stage must spawn. When > 0, the runner inspects stateStore.getState().agents before and after the stage and retries once with an explicit spawn_worker instruction if no new workers appeared. Currently honored by /feature.

Frontmatter parser quirks (important)

agents-fleet uses a hand-rolled YAML-subset parser (src/skills/parser.ts:46-163). Behaviors worth knowing when authoring stages:

  • Hyphens in keys are allowed by the nested-property regex at parser.ts:103 (/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/). The runtime only reads the documented spellings, so prefer those.
  • workers_required: and workersRequired: are both accepted for the same WorkflowStage.workersRequired field (parser.ts:617-628). The snake_case form is used throughout the bundled workflows; camelCase exists as belt-and-braces.
  • artifact: is the only spelling for the artifact field — no artifactName: alias (parser.ts:611-613).
  • Booleans, integers, and inline arrays are coerced inside nested list-item properties (parser.ts:119-135). So parallel: true parses as boolean true, workers_required: 4 as the number 4, and agents: [a, b, c] as a 3-element array.
  • stages: [] (empty inline array) is the supported freeform shape. See freeform.workflow.md.
  • Top-level keys are scoped to a single line (parser.ts:138-157). Use YAML block lists for nested stage definitions. Multi-line scalars are not supported.

Workflow completion metadata

Workflows may declare a top-level completion block so unattended runners can recognize that the workflow's durable outputs exist even if the coordinator session does not return promptly:

yaml
completion:
  required_artifacts:
    - 01-brainstorming.md
    - 02-requirements.md
  stable_checks: 2
  poll_interval_ms: 1000
  • required_artifacts (or requiredArtifacts) lists filenames relative to the active plan directory. All listed files must exist and be non-empty.
  • stable_checks (or stableChecks) is the number of consecutive stat reads with the same size and mtimeMs required before a file is considered durable.
  • poll_interval_ms (or pollIntervalMs) controls how often the runner checks while a stage send is in flight.

The metadata is opt-in. Workflows without completion.required_artifacts keep normal runner behavior.

Stage prompts (on-disk, used by /feature)

/feature's runner is unique today in that each stage has a per-stage prompt template loaded from a sibling file:

  • Convention (src/skills/workflowPrompts.ts:12-17): <workflow-name>/<stage-name>.prompt.md next to the .workflow.md file. For the bundled feature pipeline, that's src/skills/bundled/workflows/feature-pipeline/brainstorm.prompt.md, requirements.prompt.md, etc.
  • Loader (src/skills/workflowPrompts.ts:25-38): loadStagePrompt(workflow, stageName) reads the sibling; returns null when missing. The runner then emits a hard error and aborts the stage (featureCommand.ts:177-184).
  • Placeholder substitution (src/skills/workflowPrompts.ts:44-48): formatStagePrompt(template, vars) replaces ${var} placeholders. The runner passes feature, planDir, and context. Unknown placeholders are left intact so typos surface during review.

/code-review and /research do not use sibling stage prompts — they build their prompts from each role's systemPrompt via the registry.


Bundled workflows

Sixteen workflows ship in src/skills/bundled/workflows/ (v0.36.3). The six command-backed defaults consulted by the workflow-driven slash commands are documented in detail below; the full catalog is:

Workflowcommand:Description
feature-pipeline— (via /feature)7-phase feature planning (brainstorm → requirements → research → design → validation → DoD → tasks).
code-review/code-reviewComprehensive multi-agent code review.
init-investigation/initParallel investigators + critic + synthesizer → .fleet/context/.
adversarial-research/researchAdversarial deep research (parallel explorers + critic).
planningMulti-step planning with adversarial research, DoD, task breakdown, execution.
freeformNo-op workflow for ad-hoc/freeform crew operation.
brainstorm— (via /brainstorm)3 parallel adversarial inquisitors grill the user pre-feature.
bug-autopilot/bug-autopilotReproduce → fix → regression-test → review loop → PR → approval → merge.
code-review-autopilot/code-review-autopilotIterative review → triage → fix → validate; optional --ship PR handoff.
fixer-loop/fixer-loopAuto-pilot review/triage/fix/verify loop until convergence or budget exhausted.
github-issue-worker-autopilot/github-issue-worker-autopilotTriage open GitHub issues → fix → validate → proof artifacts → draft PR.
triageInvestigate then branch (fix / review / skip) on artifacts + ifResult.
approval-gateDemo: build → human-approval interrupt → deploy (#369 Phase 3C).
per-file-reviewDemo: fan-out per-file reviews → single digest (#369 Phase 3B).
compose-reviewDemo: per-file reviews → approval interrupt → conditional apply/skip → report.
subgraph-exampleDemo: parent workflow that invokes code-review as a subgraph stage.

They're also the defaults consulted by the workflow-driven slash commands.

feature-pipeline

  • File: src/skills/bundled/workflows/feature-pipeline.workflow.md
  • Sibling prompts: feature-pipeline/*.prompt.md (7 files)
  • Driven by: /feature (default) — Version 2

7-phase feature planning pipeline that scopes a change against an existing codebase. Each phase produces a numbered Markdown artifact under .plans/<slug>/. It declares all seven numbered files in completion.required_artifacts so unattended /feature --new can shut down once the final artifact is durable.

#StageAfterArtifactworkers_required
1brainstorm01-brainstorming.md
2requirements[brainstorm]02-requirements.md
3research[requirements]03-research.md4
4design[research]04-technical-design.md3
5validation[design]05-validation.md3
6dod[validation]06-definition-of-done.md
7tasks[dod]07-task-breakdown.md
/feature "Add JWT authentication to the API"
/feature --new "Start fresh even if a plan exists"
/feature --resume i-want-the-48448b

Authoring caveats. Requires sibling prompts — the runner aborts a stage with prompt file missing if any prompt is absent (often because npm run build wasn't run after editing in dev). The tasks stage has a runner-specific re-run when the artifact exists but no tasks were registered via task_create (featureCommand.ts:413-432).

code-review

  • File: src/skills/bundled/workflows/code-review.workflow.md
  • Driven by: /code-review (default) — Version 1
#StageParallelAfterAgents
1reviewarchitecture-reviewer, correctness-reviewer, performance-reviewer, security-reviewer
2synthesize[review]
/code-review
/code-review src/auth

Authoring caveats. The runner picks the first stage with a non-empty agents: list as the reviewer roster (codeReview.ts:78-82). The synthesis step is performed by the coordinator inline at the end of the same prompt — subsequent stages are not walked.

init-investigation

  • File: src/skills/bundled/workflows/init-investigation.workflow.md
  • Driven by: /init (default — but see caveat) — Version 1
#StageParallelAfterAgents
1investigateinit-arch-explorer, init-build-explorer, init-pattern-explorer, init-decision-explorer, init-dep-explorer, init-api-explorer, init-security-explorer
2critic[investigate]init-critic
3synthesize[critic]
/init
/init refresh

Authoring caveats. Resolved via resolveCommandWorkflow; the runner walks stages[] via the shared runWorkflowStages in sequential mode (src/commands/initCommand.ts:167-179). The first stage with parallel: true and a non-empty agents: list is the explorer roster; the first subsequent stage with an after: dependency and a non-empty agents: list is the critic stage; stages with no agents are synthesis pauses. A crew override that points workflows.init at a different workflow now actually spawns the override's agents (this was previously a known bug — see the v0.22.0 CHANGELOG entry).

adversarial-research

  • File: src/skills/bundled/workflows/adversarial-research.workflow.md
  • Driven by: /research (default) — Version 1
#StageParallelAfterAgents
1researchprimary-researcher, counter-researcher, context-researcher, edge-case-researcher
2critic[research]research-critic
/research "best approach to implement auth with JWT vs sessions"

Authoring caveats. /research partitions agents into two buckets: parallel: true stages contribute parallel researchers; non-parallel stages contribute sequential agents — and only the first sequential agent is used as the critic (researchCommand.ts:76-107). Author with one critic in a second sequential stage, not many.

planning

  • File: src/skills/bundled/workflows/planning.workflow.md
  • Driven by: no slash command — consumed by crews as their workflow: field (e.g. agents-fleet-crew). Coordinator reads the body as the <active-workflow> block.
#StageParallelAfter
1research
2dod[research]
3tasks[dod]
4critic[tasks]
5execute[critic]

This workflow is mostly prose in the body — the planning discipline itself is the contract, not the stage list.

freeform

  • File: src/skills/bundled/workflows/freeform.workflow.md
  • Driven by: no slash command. Default workflow: for crews authored without one (parser.ts:416, 764).
yaml
---
name: freeform
description: No-op workflow for ad-hoc/freeform crew operation
stages: []
---

Zero stages by design. Any slash-command resolution that lands on freeform will fail with Workflow "freeform" has no stages. — that's intentional. freeform is for crew bodies, not command overrides.


Authoring custom workflows

In .fleet/workflows/

Drop a *.workflow.md file in either:

  • <cwd>/.fleet/workflows/ (project tier — committed to the repo)
  • ~/.fleet/workflows/ (user tier — personal, all projects)

Both directories are scanned by loadWorkflowsFromDir and the result is upserted into the registry (SkillRegistry.ts:209-211). Tier precedence is project > user > bundled — a project-tier feature-pipeline.workflow.md silently replaces the bundled one. (For a custom /feature pipeline this is fine; for accidental shadowing it can be confusing — list with /skills to verify what's loaded.)

Quick sanity check after authoring:

/skills        # workflows you authored show up under "Workflows"

When to override vs. extend

Use caseWhat to author
Take over a slash command (e.g. /feature runs your pipeline)A custom *.workflow.md + a custom *.crew.md that sets workflows: { feature: <your-workflow> }. Activate the crew with /crew activate <name>.
Drive a freeform crew's coordinator with stage-discipline proseA custom *.workflow.md + a crew with workflow: <your-workflow> (top-level, not the per-command map).
Pair a new researcher persona with /researchA custom researcher crew + a custom adversarial-research-shaped workflow + workflows: { research: <your-workflow> }.

Worked example — a 3-stage release-prep workflow

Drop in .fleet/workflows/release-prep.workflow.md:

markdown
---
name: release-prep
description: 3-stage release prep — changelog → version-bump → tag-and-push
stages:
  - name: changelog
    artifact: CHANGELOG.draft.md
  - name: version-bump
    after: [changelog]
    artifact: package.json.bumped
  - name: tag-and-push
    after: [version-bump]
version: 1
---

## Release Prep Workflow

Sequential stages; each stage produces an artifact the runner can verify.

For sibling stage prompts (so a workflow-driven runner can substitute ${feature} / ${planDir} / ${context}), add files at .fleet/workflows/release-prep/<stage>.prompt.md.

To drive the workflow, either:

  • Declare a command: field on the workflow's frontmatter and the registry will synthesise a /release-prep slash command automatically. See Authoring auto-discovered commands.
  • Consume it in freeform mode from a release-prep crew that sets workflow: release-prep at the top level.
  • Wire it as an override for one of the four workflow-driven commands by activating a crew with workflows: { feature: release-prep } (or similar). The shared runWorkflowStages will walk it the same way it walks the bundled defaults.

Authoring auto-discovered commands

Drop a *.workflow.md file with a command: field on its frontmatter and the registry synthesises a slash command that runs the workflow via runWorkflowStages. No TypeScript changes required.

Minimum example

.fleet/workflows/release-prep.workflow.md:

markdown
---
name: release-prep
description: 3-stage release-readiness check
command: release-prep
command_aliases: [rp, ship]
command_usage: /release-prep <version>
args:
  - name: version
    required: true
    description: Semver tag to prepare, e.g. v0.22.0
stages:
  - name: audit
    agents: [security-reviewer, correctness-reviewer]
    parallel: true
  - name: notes
    after: [audit]
version: 1
---

## Release Prep Workflow

…workflow body…

After this file lands in ~/.fleet/workflows/ (user tier) or <cwd>/.fleet/workflows/ (project tier — see Security clamp), the synthesised command appears in /help with a tier suffix:

> /help

  /release-prep (rp, ship) — 3-stage release-readiness check  [workflow:user]

REPL invocation works immediately:

> /release-prep v0.22.0

Frontmatter fields

FieldTypeRequiredNotes
commandstringThe slash command name (without leading /). Validated via isValidSlashCommandName (src/skills/isValidSlashCommandName.ts:93).
command_aliasesstring[]optionalExtra names the command answers to. Each is validated the same way. commandAliases also accepted.
command_usagestringoptionalFree-form usage line printed in /help and on arg-validation failure. commandUsage also accepted.
argsWorkflowArgSpec[]optionalPositional args. Each is { name, required?, description? } (src/skills/types.ts:142-149). When a required arg is missing at invocation time the synthesised handler prints the command_usage (or a default hint) and returns without sending to the coordinator.

Reserved names and collisions

The set of always-rejected names lives in RESERVED_COMMAND_NAMES (src/skills/isValidSlashCommandName.ts:41-49): help, exit, quit, q, clear, version, v. These route through REPL-level infrastructure (context.onShutdown, alias convergence) and silent shadowing would break core behaviour. Names starting with - are also rejected (flag shape).

Collisions are resolved at discovery time (src/commands/discoverWorkflowCommands.ts:99):

  • Built-in collision — a workflow whose command: (or alias) matches a hand-curated SlashCommand is rejected with a startup warning. The built-in wins; the workflow's body is still loadable, just not invokable as a slash command.
  • Synthesised-vs-synthesised collision — two workflows declaring the same command: refuse both synthesised commands (defensive — neither can claim the slot deterministically). Resolve by renaming one of them.
  • Tier precedence — when the same command: is declared at multiple tiers, the higher tier wins (project > user > bundled), and the [SkillRegistry] OVERRIDE WARNING: is emitted on startup the same way it fires for skill / role / crew overrides.

/help rendering

Synthesised commands appear in /help with a dim [workflow:<bundled|user|project>] suffix (src/commands/registry.ts:54-58). Built-ins render unchanged. This is the fastest way to tell whether /release-prep is a built-in or auto-discovered, and which tier shipped it.

Security clamp — project-tier trust

Project-tier auto-discovery is opt-in. By default, dropping a command:-bearing workflow into <cwd>/.fleet/workflows/ does not synthesise a slash command — an attacker-controlled repo could otherwise inject commands by being cloned. The trust resolver (src/commands/resolveTrustProjectWorkflows.ts:35) consults three sources in order:

  1. --trust-project-workflows CLI flag (src/index.ts:104)
  2. Env var AGENTS_FLEET_TRUST_PROJECT_WORKFLOWS=1 (strict '1')
  3. Config key workflowDiscovery.trustProjectSources: true in ~/.fleet/config.json (src/config.ts:79)

When project-tier workflows are silently skipped, a one-line startup notice surfaces the escape hatch (src/index.ts:338-345):

  ⚠️  Skipped 1 project-tier workflow command(s) (release-prep) for security (SEC-1 mirror).
     Run with --trust-project-workflows, set AGENTS_FLEET_TRUST_PROJECT_WORKFLOWS=1, or add
     "workflowDiscovery": { "trustProjectSources": true } to ~/.fleet/config.json to enable.

User-tier and bundled-tier workflows are not gated — they're authored or shipped by the operator, not by cloned code.

Known limitations

  • You cannot disable a bundled workflow's auto-discovery from a higher tier. A project-tier override of feature-pipeline overrides the workflow body but cannot turn OFF the bundled command: feature (it doesn't exist on the bundled workflow today, but the principle applies if a future bundled workflow ships with command:).
  • args: does not support pattern: regex validation. Each arg has name + required? + description? only — argument-shape validation is out of scope for v1. Stronger validation lives in the workflow body or in stage prompts.

Per-command override mechanism (T-148)

How it works

The single source of truth is resolveCommandWorkflow in src/skills/workflowResolver.ts:34-77:

ts
resolveCommandWorkflow(
  registry: SkillRegistry | undefined,
  activator: CrewActivator | undefined,
  commandName: string,
  bundledDefault: string,
): ResolvedWorkflow | ResolvedWorkflowError

Resolution order:

  1. Active crew's workflows[commandName]. If a crew is active and its frontmatter declares workflows: { <commandName>: <wfName> } and registry.getWorkflow(wfName) returns a workflow with at least one stage → use it. The result is tagged source: 'crew'.
  2. Bundled default. Otherwise resolve bundledDefault from the registry. Tagged source: 'default'.

Missing or typo'd overrides surface as a hard error rather than a silent fallback (workflowResolver.ts:53-58):

Active crew "X" maps /Y → "Z", but that workflow is missing or has no stages. Fix the crew file or remove the override.

This is intentional. If the active crew configured an override, the user already declared intent — quietly running the default instead would mask the misconfiguration.

Which commands honor the override

Four commands wire resolveCommandWorkflow today:

CommandDefault workflowSource
/featurefeature-pipelinesrc/commands/featureCommand.ts:113-114, 279-294
/code-reviewcode-reviewsrc/commands/codeReview.ts:30-31, 60-75
/initinit-investigationsrc/commands/initCommand.ts:37-38, 140-156 (after the status / scopes / load subcommand router)
/researchadversarial-researchsrc/commands/researchCommand.ts:27-28, 53-68

In every case, the resolved workflow's name is printed in the command banner, and a yellow ⚠ warning is emitted when source === 'crew' and the workflow name differs from the bundled default (e.g. featureCommand.ts:348-353).

Sample crew override frontmatter

yaml
---
name: docs-crew
description: Docs crew that overrides /code-review and /feature
topology: hub
workflow: freeform
workflows:
  feature: my-feature-pipeline      # /feature uses my-feature-pipeline
  code-review: strict-review        # /code-review uses strict-review
coordinator:
  role: default-coordinator
agents:
  - role: doc-writer
  - role: doc-reviewer
---

Notes:

  • Keys must match /^[a-z][a-z0-9-]*$/ (kebab-case, leading letter). Invalid keys are dropped with a parser warning (parser.ts:791-810).
  • Values are workflow names, resolved at command invocation time — so a workflow added later still resolves.
  • Unknown command names are stored but never consulted; harmless for forward-compat.

When NOT to use the override

The workflows: map is only consulted by the four commands above. Other slash commands ignore it entirely — /start, /loop, /loop-target, /skill, /skills, /crew, /crews, /tasks, /dod, /diagnose, /workers, /topology, etc. Declaring workflows: { start: … } in a crew is silently ignored.


Runner semantics

Stage execution model

All workflow-driven runners share the same conceptual model, even if today's implementations don't all walk every stage:

  • Sequential stages (no parallel, or parallel: false) run one at a time in declared order. /feature calls coordinator.sendAndWait(...) once per stage (featureCommand.ts:380-440).
  • Parallel stages (parallel: true) tell the runner to instruct the coordinator to spawn every agent in stages[N].agents[] simultaneously via spawn_worker. The runner does not call spawn_worker itself — it tells the coordinator to, then verifies via the workersRequired retry that it actually happened.
  • after: [stage-name] documents predecessor stages. Honored by iteration order in /feature; informational for freeform consumers. There is no topological-sort pass — after: documents intent; the runner trusts the array order.
  • after: 'all' is shorthand for "depends on every prior stage" (parser.ts:603-604). No bundled workflow uses it.
  • gates: [...] is reserved metadata for approval gates. No runner inspects this field today. Authoring it is harmless and forward-compat; do not rely on it for enforcement.

Artifact retry (artifact:)

When a stage declares artifact: <filename> and the file doesn't exist at <planDir>/<filename> after the stage completes, the runner retries once with an explicit "you MUST call create to write <filename>" instruction appended to the prompt (featureCommand.ts:228-244). If the file is still missing after the retry, the runner aborts the pipeline with Phase N failed to produce <filename> after retry. Currently honored by /feature only.

Workers-required retry (workers_required:)

When a stage declares workers_required: N (N > 0), the runner snapshots stateStore.getState().agents before sending the stage prompt, then compares after the stage completes. If zero new worker IDs appeared, the runner retries once with an explicit "you MUST call spawn_worker N times in parallel" instruction (featureCommand.ts:206-226). Currently honored by /feature only.

Why a retry instead of a hard fail? Coordinators sometimes answer inline ("here's the brainstorm") instead of orchestrating workers. The retry surfaces the disciplinary instruction explicitly so the next pass usually behaves; the failure mode is recoverable without losing the user's in-progress plan dir.

Generic stage runner

All four workflow-driven commands and every auto-discovered command share a single entry point: runWorkflowStages(workflow, ctx, opts) in src/skills/workflowRunner.ts:194. Mode is selected by opts.mode or auto-detected from the workflow's shape.

ModeWhen to useBehaviour
rosterSingle-roster commands like /code-review.Spawns stages[0].agents (the first stage with a non-empty agents: list) in parallel and ignores everything else. Subsequent stages are decorative.
sequentialMulti-stage commands without per-stage prompt files (/research, /init).Iterates stages[] in order; stages with parallel: true spawn their agents: simultaneously, sequential stages run one agent at a time; after: [stage] is honoured by iteration order. No per-stage prompt template is loaded — callers supply a single coordinator prompt.
per-stage-promptPer-stage prompt-template walkers like /feature.Walks stages[] and for each calls coordinator.sendAndWait with the formatted sibling <workflow>/<stage>.prompt.md template (${feature} / ${planDir} / ${context} substitution). Verifies the stage's artifact: after the send (single retry with an explicit create instruction) and asserts workers_required: produced N new workers (single retry with an explicit spawn_worker instruction).

Three guarantees hold across modes:

  • Same artifact retry semantics. When a stage declares artifact: and the file doesn't exist after the stage completes, the runner retries once with an explicit "you MUST call create" instruction.
  • Same workers_required retry semantics. When a stage declares workers_required: N and zero new workers spawned, the runner retries once with an explicit "you MUST call spawn_worker N times" instruction.
  • Consistent error handling. runWorkflowStages returns { stagesRun, agentsSpawned, errors }; callers decide whether errors abort or warn.

This shared runner replaces three previously inline iteration loops (one per workflow-driven command), so any author of a new auto-discovered workflow command inherits consistent stage-walking semantics for free.

Stage-walking differences (the three modes)

All four workflow-driven commands now share runWorkflowStages (src/skills/workflowRunner.ts:194), which exposes three modes preserving each command's historical contract. See Generic stage runner for the full mode reference.

CommandModeWalking pattern
/featureper-stage-promptFull walker. Iterates stages[] in order, calling coordinator.sendAndWait once per stage. Each stage uses its sibling <workflow>/<stage>.prompt.md template with ${feature} / ${planDir} / ${context} substitution. Verifies artifact and workers_required per stage with single-retry. Skips stages whose artifact already exists on resume. See src/commands/featureCommand.ts.
/code-reviewrosterFirst-roster reader. Finds the first stage where agents.length > 0 and uses that as the reviewer roster. Builds a single combined prompt that asks the coordinator to spawn all reviewers in parallel, then synthesise. Does not walk subsequent stages. See src/commands/codeReview.ts.
/researchsequentialTwo-bucket partitioner. Iterates all stages: agents from parallel: true stages become the researcher list; agents from non-parallel stages become the sequential agents (the first sequential agent is the critic). Builds a single combined prompt. See src/commands/researchCommand.ts.
/initcoordinator-per-stageSynthesized from init-investigation.workflow.md (PR-4). The bespoke handler was deleted; the workflow synthesizer now owns the slash name. The completion.monitor: true flag wires onWorkflowComplete, and completion.seedPlaceholders pre-seeds the three workflow-completion artifacts (FLEET.md, profile.json, scopes.json) via src/skills/artifactSeeder.ts. The read-only subcommands moved to /context-status, /context-scopes, /context-load.

If you author a custom workflow intended for /code-review or /research, remember: the only thing the runner reads is the stages[].agents lists plus parallel. Stage names, after:, artifact:, workers_required:, and gates: are ignored by those runners today.


Conditional edges (next:) — #369 Phase 3A

A stage may declare a next: block that picks the successor stage at runtime based on a closed vocabulary of side-effect-free predicates. When omitted, stages advance linearly (byte-identical to pre-feature behavior).

See src/skills/conditionalEdges.ts, src/skills/types.ts (NextSpec, ConditionalEdge), and .plans/fleet-graph-evolution/02-phase3-conditional-edges/01-plan.md §3(c).

YAML grammar

<next>          ::= { edges: <edges>, else?: <goto> }
<edges>         ::= [ <edge> , ... ]
<edge>          ::= <predicate> & { goto: <goto> }
<predicate>     ::= { ifArtifact: <string> }
                  | { ifArtifactMissing: <string> }
                  | { ifPlaceholder: <placeholderTest> }
                  | { ifEnv: <envTest> }
                  | { ifResult: <resultTest> }
<placeholderTest> ::= { name: <string>, (equals|contains|matches): <string> | exists: <bool> }
<envTest>       ::= { name: <ENV_ALLOWLIST_MEMBER>, equals?: <string>, exists?: <bool> }
<resultTest>    ::= { stage: <prior-stage>, (satisfied|retried|errored): <bool>
                                       | agentsSpawnedAtLeast: <non-negative-int> }
<goto>          ::= <stageName> | "end"

The predicate vocabulary is closed: exactly five if* heads plus else. No combinators (and: / or: / not:), no embedded expressions (script: / expr: / jsonpath:). Adding a head requires an open issue and a real workflow demonstrating need.

Evaluation order & semantics

  • Edges are evaluated in declaration order; the first match wins and later edges are not consulted (short-circuit).
  • If no edge matches and else: is set → goto else.
  • If no edge matches and no else:lenient fall-through to the next declared stage with a one-time warning (Q2 default).
  • goto: end terminates the workflow cleanly (no error).
  • Skipped intermediate stages are not counted in result.stagesRun and their onStageStart / onStageComplete hooks do not fire.
  • Backward goto: (to self or earlier-declared stage) is rejected by the parser unless workflow-level cycles: true is set (Q4 opt-in). Even when allowed, the runtime enforces a hard maxStageVisits cap (default 50) to bound runaway loops.
  • ifEnv may only read names in the runtime allow-list (CI, GITHUB_ACTIONS, AGENTS_FLEET_UNATTENDED, AGENTS_FLEET_LANGGRAPH_DISABLED, AGENTS_FLEET_WATCHDOG_DISABLED, NODE_ENV). Anything else is rejected at parse time — secrets never influence routing.
  • ifArtifact / ifArtifactMissing paths resolve through resolveArtifactPath(planDir, …) so ../ traversal is impossible.
  • ifPlaceholder.matches patterns are length-capped (200 chars), shape- filtered against catastrophic-backtracking, and budgeted at 50ms.

Bundled example

See src/skills/bundled/workflows/triage.workflow.md for an end-to-end example exercising ifArtifact, ifPlaceholder, ifResult, and else.

yaml
stages:
  - name: investigate
    agents: [triage-investigator]
    next: { edges: [{ ifResult: { stage: investigate, errored: true }, goto: end }] }
  - name: classify
    after: [investigate]
    next: { edges: [{ ifArtifact: "triage-report.md", goto: route }], else: skip }
  - name: route
    after: [classify]
    next:
      edges:
        - { ifPlaceholder: { name: severity, equals: high },   goto: fix }
        - { ifPlaceholder: { name: severity, equals: medium }, goto: review }
      else: skip
  - name: fix
    agents: [fixer]
  - name: review
    agents: [reviewer]
  - name: skip
    agents: [recorder]

Defaults applied (#369 breakdown)

  • Q1 — vocabulary: ifArtifact / ifArtifactMissing / ifPlaceholder / ifEnv / ifResult + else. No combinators.
  • Q2 — no-match policy: lenient fall-through with one-time warning.
  • Q4 — cycles: workflow-level cycles: true opt-in plus runtime maxStageVisits: 50 cap (override per call via runWorkflowStages options).

Limitations

Be honest about what the workflow system does and doesn't do.

  • No loop: / iterate: / repeat-until: primitive. Stages form a DAG, not a loop. Express "repeat until DoD passes" at a layer above (e.g. /loop "<goal>" <interval> against the fleet — see docs/commands-manual.md).
  • No size-aware planning primitive. No "split into N chunks" stage. Large feature plans see coordinator context pressure; chunking is a manual concern today.
  • gates: is parsed but uninspected. Authoring it does not block progression on any runner.
  • /code-review reads only the first roster-bearing stage; later stages are decorative.
  • /research collapses to two buckets. All parallel-stage agents are flattened into one researcher list; only the first sequential-stage agent is used as critic.
  • Both retries fire at most once. If the second attempt also fails, the pipeline aborts. No exponential backoff.
  • workers_required: retry only fires when zero new workers spawn. Spawning fewer than N (but at least 1) does not trigger the retry.
  • No cross-stage variable passing beyond ${feature} / ${planDir} / ${context}. Stages exchange data only by reading artifacts from disk (which bundled prompts do explicitly).
  • Cannot disable a bundled workflow's command: from a higher tier. A project-tier override replaces the workflow body but cannot turn OFF a bundled command: field — auto-discovery always fires from whichever tier wins precedence.
  • args: does not support pattern: regex validation. Each arg has name + required? + description? only — argument-shape validation is out of scope for v1.

Cross-references

  • For crews + the activator lifecycle, see docs/composition.md §Crews and §Workflows (the existing crews-reference doc).
  • For commands that drive workflows and the full command catalog, see docs/commands-manual.md.
  • For the end-to-end planning tutorial (/init/feature/start), see docs/INTRO.md and docs/commands-manual.md.
  • For the v0.21.0 release notes that introduced T-148 (per-command override) and the /feature rewrite, see CHANGELOG.md § 0.21.0.

Fan-out / Fan-in (Map pattern)

Status: Phase 3B (#369). Supported in runner: per-stage-prompt and runner: coordinator-per-stage. Not supported in sequential or roster modes.

A stage can fan out across a list of items, run the same prompt (or subgraph) once per item in parallel, and then gather the per-item outputs back into a single artifact.

Grammar

yaml
- name: review-files
  forEach:
    items: [src/a.ts, src/b.ts, src/c.ts]  # one of the 4 item-source shapes below
    asContext: file                         # context key for the current item (default: "item")
    parallel: true                          # default true
    maxConcurrency: 4                       # soft cap; default min(8, items.length)
    onError: gather-all                     # gather-all (default) | fail-fast
    forceLargeFanOut: false                 # required when inline items.length > 50
    retry: { max: 2, backoff: { type: fixed, delayMs: 500 } }  # optional, overrides stage/workflow defaults
    gather:
      strategy: { kind: concat, header: "# Reviews", separator: "\n\n---\n\n" }
      artifact: per-file-reviews.md
      # placeholder: reviewSummary           # alternative/additional sink

Item sources

Four shapes for forEach.items:

SourceShapeNotes
inline (array)items: [a, b, c] or items: { values: [a, b, c] }Inline literal. Capped at 50 entries unless forceLargeFanOut: true.
placeholderitems: "{{name}}" or items: "${name}"Reads from the workflow placeholder map. Auto-detects JSON array vs newline-separated lines.
artifactitems: { artifact: path.md, format: lines }Reads <planDir>/path.md. format is lines (default) or json. Path-traversal guarded via resolveArtifactPath.
commanditems: { command: "git diff --name-only HEAD~1", format: lines }Default-denied. Requires AGENTS_FLEET_FOREACH_COMMAND=1 env. 30-second timeout, 4 MiB stdout cap. No project-tier trust escalation in v1.

Empty resolution is a no-op (stage succeeds with perItem: []). items.length > 50 is rejected unless forceLargeFanOut: true is also set (cost-guard). items.length > 10 emits a soft warning via onWarning. With forceLargeFanOut: true, a console.warn is emitted unconditionally.

Gather strategies

StrategyOutput shapeUse case
array (default)JSON array of { item, output }Programmatic consumers / next-stage iteration.
concatPlain text joined with optional header + separatorPer-file reviews → single markdown report.
mergeBest-effort merge of JSON object outputs (last-write-wins)Aggregating structured outputs.

If gather.artifact is set, the gathered payload is written via resolveArtifactPath (path is sandboxed to the workflow's cwd — .. segments are rejected).

Per-item context

Each spawned worker receives, in addition to the stage's normal context:

  • {{<asContext>}} — the current item (default key: item)
  • {{__forEachIndex}} — the 0-based index of the item

All prompt placeholders are expanded via expandPlaceholders before spawn, so prompt bodies can reference {{file}}, {{__forEachIndex}}, etc. If asContext collides with a parent placeholder, the per-item value shadows the parent and a single warning is emitted per stage.

Retry resolution

Per-item retry policy is resolved in order: forEach.retrystage.retryworkflow.defaultRetry. Retry is per-item; one item's failure does not consume another's attempts.

Subgraph + forEach

A forEach stage may set subgraph: instead of inline prompt:/agents:. The whole subgraph then runs once per item. forEach + agents: on the same stage is rejected at parse time.

Coexistence warnings

  • parallel: true + agents.length > 1 + forEach: → multiplicative spawning warning.
  • forEach + gather-all is the default and intended path; fail-fast aborts in-flight items via AbortController on first failure.

Result shape

Stages with forEach populate two extra StageResult fields:

  • perItem: PerItemResult[] — one entry per resolved item with { item, index, status: 'fulfilled' | 'rejected' | 'cancelled', output?, error?, attempts }.
  • gathered: { artifactPath?, payload } — the aggregated payload.

A forEach stage's satisfied is true iff the base satisfaction predicate AND at least one item fulfilled. Zero fulfillments produces error.kind = 'foreach_all_failed'.

Assumptions / limitations

  • maxConcurrency is a soft cap; FleetManager's global pLimit(12) is the only hard ceiling on parallel workers.
  • Gather runs after each item's spawn returns — store-drain ordering (see #354) layers on top.
  • command: source is default-denied and has no project-tier trust escalation in v1.
  • Token-based cost capping is deferred to a follow-up; v1 uses an item-count cap (50, opt-out via forceLargeFanOut).

See src/skills/bundled/workflows/per-file-review.workflow.md for a runnable example.

Conditional Edges (#369 Phase 3A)

Stages may declare a next: block that replaces linear advancement with declarative, predicate-driven routing.

Grammar (BNF)

next        ::= "{" "edges" ":" edge-list ("," "else" ":" goto)? "}"
edge-list   ::= "[" edge ("," edge)* "]"
edge        ::= "{" predicate "," "goto" ":" goto "}"
predicate   ::= "ifArtifact"        ":" string
              | "ifArtifactMissing" ":" string
              | "ifPlaceholder"     ":" placeholder-test
              | "ifEnv"             ":" env-test
              | "ifResult"          ":" result-test
goto        ::= "end" | stage-name
placeholder-test ::= "{" "name" ":" string "," (equals|contains|matches|exists) "}"
env-test         ::= "{" "name" ":" ENV_ALLOWLIST "," (equals|exists) "}"
result-test      ::= "{" "stage" ":" stage-name "," (satisfied|retried|agentsSpawnedAtLeast|errored) "}"

Rules

  • Five predicate heads (closed vocabulary): ifArtifact, ifArtifactMissing, ifPlaceholder, ifEnv, ifResult. Exactly one per edge. No script: / expr: / jsonpath: heads — by design.
  • First-match wins — edges are evaluated in declaration order. The rest of the list is skipped once an edge matches.
  • else: fallback — fires when no edge matches. Use "end" to terminate the workflow cleanly.
  • No-match + no-else — lenient fall-through to the next declared stage with a single onWarning event (NOT a hard error).
  • ifEnv allow-list — only CI, GITHUB_ACTIONS, AGENTS_FLEET_UNATTENDED, AGENTS_FLEET_LANGGRAPH_DISABLED, AGENTS_FLEET_WATCHDOG_DISABLED, NODE_ENV are readable. Programmatic bypass is impossible — the evaluator double-checks.
  • ifPlaceholder.matches regex safety — patterns capped at 200 chars; isSafeRegex() rejects catastrophic-backtracking shapes ((a+)+, (a|a)+, etc); each test is wall-clock-bounded at 50 ms.
  • ifResult.stage must refer to the current stage or an earlier-declared stage (forward refs are rejected at parse time).
  • Backward goto: (to current or earlier stage) — rejected at parse time unless the workflow declares cycles: true. Runtime enforces a hard maxStageVisits cap (default 50) regardless.
  • Skipped stages do not fire hooks and do not contribute to stagesRun / agentsSpawned counters.

Example — triage workflow

yaml
---
name: triage
description: Investigate then branch — fix / review / skip
stages:
  - name: investigate
    agents: [triage-investigator]
    next: { edges: [{ ifResult: { stage: investigate, errored: true }, goto: end }] }
  - name: classify
    after: [investigate]
    next: { edges: [{ ifArtifact: "triage-report.md", goto: route }], else: skip }
  - name: route
    after: [classify]
    next:
      { edges: [
        { ifPlaceholder: { name: severity, equals: high },   goto: fix },
        { ifPlaceholder: { name: severity, equals: medium }, goto: review }
      ], else: skip }
  - name: fix
    agents: [fixer]
    next: { edges: [], else: end }
  - name: review
    agents: [reviewer]
    next: { edges: [], else: end }
  - name: skip
    agents: [recorder]
---

Routes covered:

  1. investigate errors → straight to end.
  2. No triage-report.md written → classify falls to else: skip.
  3. severity=highfix; severity=mediumreview; otherwise → skip.

Cycles (opt-in)

yaml
---
name: retry-until-clean
cycles: true
stages:
  - name: build
    agents: [builder]
    next: { edges: [{ ifResult: { stage: build, satisfied: false }, goto: build }], else: ship }
  - name: ship
    agents: [shipper]
---

The runtime visit cap (maxStageVisits, default 50) still bounds execution even with cycles: true set — pathological loops fail with a visit cap exceeded error on the most-recently-visited stage.

Inline-vs-block YAML

The bundled hand-rolled YAML parser supports the conditional-edges grammar in inline form (next: { edges: [{...}, {...}], else: end }). Multi-line block form for next.edges entries is not supported in the current parser — keep your next: declarations on one (potentially long) line per stage. For readability, split the edges: [ ... ] array onto multiple physical lines using the YAML flow-style [ / , / ] tokens.

Interrupts (Human-in-the-loop, #369 Phase 3C)

An interrupt: stage suspends the workflow at a stage boundary and waits for a user decision before advancing.

YAML schema

yaml
- name: await-approval
  interrupt:
    waitFor: user          # v1 only
    prompt: |
      Approve deploy?
    onResume:              # OPTIONAL (see note)
      approve: continue
      reject: abort
    maxReprompts: 3        # default 3
    storeAs: answer        # optional
    timeoutMs: 86400000    # default: undefined (no timeout)
    unattendedPolicy: fail-loud   # or 'auto-approve'

Note on onResume: the bundled YAML parser only nests two levels deep (list-item + nested map). A 3rd-level map like onResume is dropped silently at parse time; the dispatcher falls back to the built-in default { approve: 'continue', reject: 'abort' }. The built-in aliases below also resolve common keywords without any onResume entry.

Decision routing

Built-in case-insensitive aliases (applied BEFORE onResume):

KeywordAction
approve / yes / y / ok / okay / continuecontinue
reject / no / n / cancel / abortabort
repeat / again / retryrepeat

Unknown answers fall through to onResume; missing keys default to repeat (re-prompt up to maxReprompts).

Slash commands

CommandPurpose
/workflows listTable of suspended checkpoints
/workflows status <thread-id>JSON dump of stored state
/workflows resume <thread-id> [continue|abort|<raw>]Resume with decision
/workflows abort <thread-id>Mark ABORTED
/workflows release <thread-id>Admin: clear stale claim
/approveShortcut: continue the sole suspended workflow in this cwd
/rejectShortcut: abort the sole suspended workflow in this cwd

Resume banner

When agents-fleet --resume <sessionId> is invoked, the loader prints a single cyan banner: N workflow(s) awaiting your input — run /workflows list to see them. Auto-resume is not performed; the user must explicitly call /approve or /workflows resume.

Storage & multi-instance safety

State is persisted via the v1 workflow_checkpoints SQLite table (WorkflowCheckpointStore — see src/intel/). A claim/release lease prevents two processes from resuming the same checkpoint concurrently; a failed claim throws CheckpointBusyError. Stale claims can be cleared with /workflows release <thread-id>.

Timeouts

Setting timeoutMs lets the on-demand sweeper (run on every --resume and every /workflows list) abort rows whose createdAt + timeoutMs has elapsed. The default is no timeout. Recommended upper bound for user-facing flows: 24h.

Known limitations (v1)

  • interrupt: is only supported by the coordinator-per-stage runner mode.
  • Custom onResume maps require flat approve/reject-style keys inside the dispatcher's default; deeper YAML nesting is not parsed (see "Note on onResume" above).
  • Subgraph interrupt propagation (a child workflow's suspended state bubbling up to suspend the parent) is not wired in v1; child suspensions return as completed-with-warning at the parent boundary. Tracked for a follow-up PR.
  • Workflows declaring interrupt: and invoked under --unattended default to fail-loud (the runner records an errors[].kind = 'interrupt.unattended' and stops). Set unattendedPolicy: auto-approve per stage to opt into silent advancement under unattended mode.

See src/skills/bundled/workflows/approval-gate.workflow.md for the canonical bundled example.