Problem
bmad-loop has no terminal state for a story whose agent-doable work is complete but whose acceptance criteria include actions only a human can perform outside the repo — buy a domain, purchase a Workspace seat, publish DNS records, get a vendor account approved.
The umbrella field report is exactly this shape: a foundation story where the agent wrote everything it could and 40 checklist entries were blocked on human external actions. The run's available outcomes were all wrong:
done — false. It's what the orchestrator wrote anyway (engine.py:2072-2080), producing a board line reading done # cannot be done - requires human.
deferred — reads as failure, triggers the stash/rollback machinery, and (with scm.rollback_on_failure enabled or a unit worktree) throws the agent's correct work away.
blocked — the only existing "a human owes an action" channel (spec Block If: → status: blocked → CRITICAL → PAUSE, devcontract.py:255-258, escalation.py:127-130) — but it halts the whole run. It is a pause, not a terminal state, and the other stories in the sprint stop with it.
What's missing is: this story is finished as far as automation goes, its work is committed, the run moves on, and a named list of human actions is owed before it can be called done.
Current shape (validated at 3c68153)
- Terminal phases are exactly
{DONE, DEFERRED, ESCALATED} — model.py:22-37, statemachine.py:38-40.
- Sprint statuses are exactly 5 —
sprintstatus.py:30 STATUS_ORDER. No human-blocked token.
- Nothing comparable is planned in
docs/ROADMAP.md or the Unreleased CHANGELOG.
COMMITTING currently transitions only to {DONE, ESCALATED} — deliberate.
Design sketch
Naming, at every layer
| Layer |
Name |
| Spec frontmatter |
status: awaiting-operator + new operator_actions: (non-empty string list) |
| Sprint board token |
awaiting-operator, ordered before done in STATUS_ORDER, not in ACTIONABLE_STATUSES |
| Engine phase |
Phase.AWAITING_OPERATOR, in TERMINAL_PHASES |
| Registry |
.bmad-loop/operator-actions.json |
| Exit command |
bmad-loop confirm <story-key> |
operator_actions is a gating contract, deliberately not the existing warnings: [] frontmatter (spec-template.md:8) — that is declared as advisory flags and currently unread; overloading it would conflate "FYI" with "this blocks completion".
Entry (dev-declared park)
The dev session finalizes status: awaiting-operator and enumerates operator_actions:. Proposed interim carrier is an engine-injected prompt (engine.py:2725-2755); the durable home is an upstream BMAD-METHOD change (below). Whether that interim ships ahead of the upstream change is open — see question 8.
devcontract accepts it as a terminal beside DONE/BLOCKED (devcontract.py:43-45, :242, :402), synthesizes no escalation, and folds a validated operator_actions list into the result — malformed actions → retry(fixable=True) with feedback.
verify_dev / verify_review accept the (awaiting-operator, awaiting-operator) spec/sprint pair, with the observed spec status selecting which pair is demanded (verify.py:1158-1202, :1565-1581), and require a non-empty valid action list on that pair. _post_dev_state_sync targets the token when observed (engine.py:2052-2080) — a forward advance, so advance() stays the sole writer and never regresses.
No-rollback: park rides the normal commit path
The work commits. _drive_story skips the review loop on an awaiting-operator spec status (journal review-skipped-awaiting-operator) and runs _park_awaiting_operator, mirroring _commit: pre_commit_gate → COMMITTING → _finalize_commit_phase, with the final-phase rule AWAITING_OPERATOR iff task.operator_actions is non-empty; journal story-awaiting-operator; commit-message suffix (awaiting operator). _skip_review_and_commit gets the same branch. Worktree integrate_unit merges on phase in (DONE, AWAITING_OPERATOR) (worktree_flow.py:514-522).
This means exactly one new statemachine transition: COMMITTING → AWAITING_OPERATOR. None of the _defer machinery is involved — no stash, no rollback, no ledger snapshot.
operator_actions is latched onto the task before advance(COMMITTING), so the existing COMMITTING crash-resume arm re-derives the final phase with zero further changes.
Notify at park (proposed shape): title story awaiting operator: <key>, body enumerating the actions plus "run bmad-loop confirm <key> when done". Whether park also writes the run-dir ATTENTION file the way escalation does is open — see question 7.
Exit (bmad-loop confirm)
bmad-loop confirm <story-key> (--list, --yes, --reverify): load the registry entry, print the enumerated actions, acknowledge per action. On confirm → spec frontmatter to done + an ## Operator Confirmation audit section (repair-write doctrine: raise on failure) → sprintstatus.advance(..., "done") (a legal forward move through the sole writer, which is precisely why the token is ordered before done) → delete the registry entry → best-effort verify.commit_paths. Prior art: decisions.apply_pre_answer (decisions.py:135-167); the registry writer mirrors decisions.py's store (atomic write; a post-commit registry failure journals, it never un-commits).
No re-drive by default; --reverify runs the policy verify commands first (deterministic). Exit codes reuse the closed ExitCode allocation only (OK / FAILURE / USAGE). New validate warnings operator.registry-stale and operator.actions-malformed must register in checks.VALIDATE_CHECKS (checks.py:43-60, asserted).
Composition with the review-contradiction stopgap (see the sibling sub-issue)
A dev-declared park is not a regression, so the contradiction stopgap never sees it. A review-demoted done → awaiting-operator is the regression, and needs a knob: [operator] on_review_demotion = "escalate" (default) | "park".
park requires the design's only never-regress exception: a narrowly-allowlisted sprintstatus.set_status(path, key, target, allowed_regressions={("done", "awaiting-operator")}) writer, with advance() itself untouched. Any other (from, to) pair raises. This is the single most reviewer-visible thing in the program and is flagged as such.
Alternatives considered
- Reuse
blocked. Rejected: it is run-halting by design (CRITICAL → PAUSE), and the point of this state is that the run continues.
- Reuse
deferred. Rejected: it means "we could not finish this, throw the attempt away". Here the attempt is correct and must be preserved and committed.
- Reuse the
warnings: [] frontmatter channel. Rejected: advisory-flags semantics; a gating contract needs its own field.
- Park outside the commit flow (a bespoke path, like
_defer's). Rejected: the whole point is that the work commits and merges normally; riding COMMITTING gets crash-resume, worktree integration, and gates for free.
- A pure sprint-board convention with no engine phase. Rejected: nothing would stop the run picking the story up again, and there'd be no place to record what the human owes.
Open maintainer questions
-
The never-regress exception. Is the allowlisted set_status((done → awaiting-operator)) acceptable, or should review demotion always escalate (dropping the park mode and PR-4 entirely)?
-
Default-on or opt-in. The sketch adds [operator] enabled = true. Proposed: default-on. Should the feature ship default-on, or opt-in for a release first?
-
operator_actions value shape. Proposed for v1: plain strings. The motivating alternative is optional structured actions carrying a deterministic verification command —
operator_actions:
- action: "publish the TXT record for domain verification"
check: "dig +short TXT _bmad.example.com"
— which bmad-loop confirm could run per action instead of taking the human's word for it (and which also makes per-action acknowledgment, owner, and staleness checks richer). Fold strings-or-objects into the schema now to avoid a later format migration, or ship v1 strings and migrate?
-
_close_declared_deferred at park. Proposed: yes, it runs at the park commit — the code that closes those entries is in the commit, and closure is advisory and sweep-re-verified. Or should it wait until confirm?
-
Re-verify on confirm. Proposed: no re-drive by default, --reverify opt-in. Should confirming instead always re-run the story's verify commands (the human's action may have changed the world the tests see)?
-
Stories mode. Proposed: deferred to a follow-up. stories.yaml mode has no sprint board — park would be spec-status + registry only, and verify_review_stories (verify.py:1584-1597) gains the same alternate status. In scope for PR-2, or a follow-up?
-
Park notification shape. Should park write the run-dir ATTENTION file the way an escalation does, or notify only? Proposed: notify-only — the whole point of this state is that it is non-blocking by design, and ATTENTION is the run-halting channel.
-
Upstream sequencing. Is the engine-injected prompt acceptable as the shipped interim carrier, or should PR-2 wait for the BMAD-METHOD skill PR (spec-template enum + step-01 routing) to land first? Proposed: ship the interim, treating the upstream ask as non-blocking — but that is a proposal, not a decision.
Unless a maintainer answers otherwise here, implementation proceeds on the proposed defaults recorded above.
PR phasing (4 PRs + upstream in parallel)
- Vocabulary (inert).
Phase.AWAITING_OPERATOR + TERMINAL_PHASES; the one COMMITTING → transition; StoryTask.operator_actions round-trip; sprint token in STATUS_ORDER before done and out of ACTIONABLE_STATUSES (plus cleanup of the dead STORY_STATUSES set, sprintstatus.py:27); RunSummary/summary() counts (engine.py:95-110, :624-640); documents.py status projection (additive, schema v1); cmd_status line (cli.py:1573-1593); TUI SPRINT_GLYPHS/STYLES and STORY_GLYPHS/STYLES (tui/widgets.py:598-614, :715-737). No writer exists yet — nothing can reach the phase. state.json carrying the new phase is forward-only (older binaries reject it; standard for phase additions, noted in the CHANGELOG).
- Entry — the park path above, plus the
[operator] policy section (→ core.toml, sync-tested), the operatoractions.py registry writer, and the park notification.
- Exit —
bmad-loop confirm, validate checks, docs.
- Composition — the
on_review_demotion knob and the allowlisted set_status. Requires PR-3 of this program and the review-contradiction sibling issue merged.
Upstream BMAD-METHOD ask (parallel track)
The dev skill is upstream (bmad-dev-auto), so any engine-injected prompt is by definition interim. Proposed upstream change (precedent: our #326 ask, filed as BMAD-METHOD #2636):
awaiting-operator added to spec-template.md's status enum, plus an operator_actions: frontmatter list ("machine-readable list of external actions a human owes; non-empty required when status is awaiting-operator").
- step-03/step-04 finalize rule: agent-complete work whose acceptance criteria include human-only external actions finalizes
awaiting-operator and enumerates the actions — never blocked.
- step-01 routing: an
awaiting-operator spec is ingested as context, not resumed (same as done).
- bmad-sprint-planning STATUS DEFINITIONS documents the new sprint token, ordered before
done.
Proposed sequencing: bmad-loop ships the interim engine-injected prompt, which would make this upstream ask non-blocking. That is the proposed answer to question 8, not a settled decision — the alternative is holding PR-2 until the upstream skill PR lands.
Constraints this design must respect
No new completion path (sessions complete only on Stop / window death); no LLM in the control loop; advance() is the sole sprint write path and never regresses (PR-4's set_status is the one deliberate, allowlisted, flagged exception); legal transitions live only in statemachine.py; every new policy field needs a core.toml entry; new validate check ids register in checks.VALIDATE_CHECKS; never edit .claude/ (canonical skills live in src/bmad_loop/data/skills/).
Part of the 0.9.0 field-report umbrella (linked parent).
Problem
bmad-loop has no terminal state for a story whose agent-doable work is complete but whose acceptance criteria include actions only a human can perform outside the repo — buy a domain, purchase a Workspace seat, publish DNS records, get a vendor account approved.
The umbrella field report is exactly this shape: a foundation story where the agent wrote everything it could and 40 checklist entries were blocked on human external actions. The run's available outcomes were all wrong:
done— false. It's what the orchestrator wrote anyway (engine.py:2072-2080), producing a board line readingdone # cannot be done - requires human.deferred— reads as failure, triggers the stash/rollback machinery, and (withscm.rollback_on_failureenabled or a unit worktree) throws the agent's correct work away.blocked— the only existing "a human owes an action" channel (specBlock If:→status: blocked→ CRITICAL → PAUSE,devcontract.py:255-258,escalation.py:127-130) — but it halts the whole run. It is a pause, not a terminal state, and the other stories in the sprint stop with it.What's missing is: this story is finished as far as automation goes, its work is committed, the run moves on, and a named list of human actions is owed before it can be called done.
Current shape (validated at
3c68153){DONE, DEFERRED, ESCALATED}—model.py:22-37,statemachine.py:38-40.sprintstatus.py:30STATUS_ORDER. No human-blocked token.docs/ROADMAP.mdor the Unreleased CHANGELOG.COMMITTINGcurrently transitions only to{DONE, ESCALATED}— deliberate.Design sketch
Naming, at every layer
status: awaiting-operator+ newoperator_actions:(non-empty string list)awaiting-operator, ordered beforedoneinSTATUS_ORDER, not inACTIONABLE_STATUSESPhase.AWAITING_OPERATOR, inTERMINAL_PHASES.bmad-loop/operator-actions.jsonbmad-loop confirm <story-key>operator_actionsis a gating contract, deliberately not the existingwarnings: []frontmatter (spec-template.md:8) — that is declared as advisory flags and currently unread; overloading it would conflate "FYI" with "this blocks completion".Entry (dev-declared park)
The dev session finalizes
status: awaiting-operatorand enumeratesoperator_actions:. Proposed interim carrier is an engine-injected prompt (engine.py:2725-2755); the durable home is an upstream BMAD-METHOD change (below). Whether that interim ships ahead of the upstream change is open — see question 8.devcontractaccepts it as a terminal besideDONE/BLOCKED(devcontract.py:43-45,:242,:402), synthesizes no escalation, and folds a validatedoperator_actionslist into the result — malformed actions →retry(fixable=True)with feedback.verify_dev/verify_reviewaccept the(awaiting-operator, awaiting-operator)spec/sprint pair, with the observed spec status selecting which pair is demanded (verify.py:1158-1202,:1565-1581), and require a non-empty valid action list on that pair._post_dev_state_synctargets the token when observed (engine.py:2052-2080) — a forward advance, soadvance()stays the sole writer and never regresses.No-rollback: park rides the normal commit path
The work commits.
_drive_storyskips the review loop on anawaiting-operatorspec status (journalreview-skipped-awaiting-operator) and runs_park_awaiting_operator, mirroring_commit:pre_commit_gate→COMMITTING→_finalize_commit_phase, with the final-phase ruleAWAITING_OPERATORifftask.operator_actionsis non-empty; journalstory-awaiting-operator; commit-message suffix(awaiting operator)._skip_review_and_commitgets the same branch. Worktreeintegrate_unitmerges on phase in(DONE, AWAITING_OPERATOR)(worktree_flow.py:514-522).This means exactly one new statemachine transition:
COMMITTING → AWAITING_OPERATOR. None of the_defermachinery is involved — no stash, no rollback, no ledger snapshot.operator_actionsis latched onto the task beforeadvance(COMMITTING), so the existingCOMMITTINGcrash-resume arm re-derives the final phase with zero further changes.Notify at park (proposed shape): title
story awaiting operator: <key>, body enumerating the actions plus "runbmad-loop confirm <key>when done". Whether park also writes the run-dir ATTENTION file the way escalation does is open — see question 7.Exit (
bmad-loop confirm)bmad-loop confirm <story-key>(--list,--yes,--reverify): load the registry entry, print the enumerated actions, acknowledge per action. On confirm → spec frontmatter todone+ an## Operator Confirmationaudit section (repair-write doctrine: raise on failure) →sprintstatus.advance(..., "done")(a legal forward move through the sole writer, which is precisely why the token is ordered beforedone) → delete the registry entry → best-effortverify.commit_paths. Prior art:decisions.apply_pre_answer(decisions.py:135-167); the registry writer mirrorsdecisions.py's store (atomic write; a post-commit registry failure journals, it never un-commits).No re-drive by default;
--reverifyruns the policy verify commands first (deterministic). Exit codes reuse the closedExitCodeallocation only (OK / FAILURE / USAGE). New validate warningsoperator.registry-staleandoperator.actions-malformedmust register inchecks.VALIDATE_CHECKS(checks.py:43-60, asserted).Composition with the review-contradiction stopgap (see the sibling sub-issue)
A dev-declared park is not a regression, so the contradiction stopgap never sees it. A review-demoted
done → awaiting-operatoris the regression, and needs a knob:[operator] on_review_demotion = "escalate"(default)| "park".parkrequires the design's only never-regress exception: a narrowly-allowlistedsprintstatus.set_status(path, key, target, allowed_regressions={("done", "awaiting-operator")})writer, withadvance()itself untouched. Any other(from, to)pair raises. This is the single most reviewer-visible thing in the program and is flagged as such.Alternatives considered
blocked. Rejected: it is run-halting by design (CRITICAL → PAUSE), and the point of this state is that the run continues.deferred. Rejected: it means "we could not finish this, throw the attempt away". Here the attempt is correct and must be preserved and committed.warnings: []frontmatter channel. Rejected: advisory-flags semantics; a gating contract needs its own field._defer's). Rejected: the whole point is that the work commits and merges normally; ridingCOMMITTINGgets crash-resume, worktree integration, and gates for free.Open maintainer questions
The never-regress exception. Is the allowlisted
set_status((done → awaiting-operator))acceptable, or should review demotion always escalate (dropping theparkmode and PR-4 entirely)?Default-on or opt-in. The sketch adds
[operator] enabled = true. Proposed: default-on. Should the feature ship default-on, or opt-in for a release first?operator_actionsvalue shape. Proposed for v1: plain strings. The motivating alternative is optional structured actions carrying a deterministic verification command —— which
bmad-loop confirmcould run per action instead of taking the human's word for it (and which also makes per-action acknowledgment, owner, and staleness checks richer). Fold strings-or-objects into the schema now to avoid a later format migration, or ship v1 strings and migrate?_close_declared_deferredat park. Proposed: yes, it runs at the park commit — the code that closes those entries is in the commit, and closure is advisory and sweep-re-verified. Or should it wait until confirm?Re-verify on confirm. Proposed: no re-drive by default,
--reverifyopt-in. Should confirming instead always re-run the story's verify commands (the human's action may have changed the world the tests see)?Stories mode. Proposed: deferred to a follow-up.
stories.yamlmode has no sprint board — park would be spec-status + registry only, andverify_review_stories(verify.py:1584-1597) gains the same alternate status. In scope for PR-2, or a follow-up?Park notification shape. Should park write the run-dir ATTENTION file the way an escalation does, or notify only? Proposed: notify-only — the whole point of this state is that it is non-blocking by design, and ATTENTION is the run-halting channel.
Upstream sequencing. Is the engine-injected prompt acceptable as the shipped interim carrier, or should PR-2 wait for the BMAD-METHOD skill PR (spec-template enum + step-01 routing) to land first? Proposed: ship the interim, treating the upstream ask as non-blocking — but that is a proposal, not a decision.
Unless a maintainer answers otherwise here, implementation proceeds on the proposed defaults recorded above.
PR phasing (4 PRs + upstream in parallel)
Phase.AWAITING_OPERATOR+TERMINAL_PHASES; the oneCOMMITTING →transition;StoryTask.operator_actionsround-trip; sprint token inSTATUS_ORDERbeforedoneand out ofACTIONABLE_STATUSES(plus cleanup of the deadSTORY_STATUSESset,sprintstatus.py:27);RunSummary/summary()counts (engine.py:95-110,:624-640);documents.pystatus projection (additive, schema v1);cmd_statusline (cli.py:1573-1593); TUISPRINT_GLYPHS/STYLESandSTORY_GLYPHS/STYLES(tui/widgets.py:598-614,:715-737). No writer exists yet — nothing can reach the phase.state.jsoncarrying the new phase is forward-only (older binaries reject it; standard for phase additions, noted in the CHANGELOG).[operator]policy section (→core.toml, sync-tested), theoperatoractions.pyregistry writer, and the park notification.bmad-loop confirm, validate checks, docs.on_review_demotionknob and the allowlistedset_status. Requires PR-3 of this program and the review-contradiction sibling issue merged.Upstream BMAD-METHOD ask (parallel track)
The dev skill is upstream (bmad-dev-auto), so any engine-injected prompt is by definition interim. Proposed upstream change (precedent: our #326 ask, filed as BMAD-METHOD #2636):
awaiting-operatoradded tospec-template.md's status enum, plus anoperator_actions:frontmatter list ("machine-readable list of external actions a human owes; non-empty required when status is awaiting-operator").awaiting-operatorand enumerates the actions — neverblocked.awaiting-operatorspec is ingested as context, not resumed (same asdone).done.Proposed sequencing: bmad-loop ships the interim engine-injected prompt, which would make this upstream ask non-blocking. That is the proposed answer to question 8, not a settled decision — the alternative is holding PR-2 until the upstream skill PR lands.
Constraints this design must respect
No new completion path (sessions complete only on Stop / window death); no LLM in the control loop;
advance()is the sole sprint write path and never regresses (PR-4'sset_statusis the one deliberate, allowlisted, flagged exception); legal transitions live only instatemachine.py; every new policy field needs acore.tomlentry; new validate check ids register inchecks.VALIDATE_CHECKS; never edit.claude/(canonical skills live insrc/bmad_loop/data/skills/).Part of the 0.9.0 field-report umbrella (linked parent).