Skip to content

awaiting-operator: terminal state for stories owing human external actions #335

Description

@pbean

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

  1. 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)?

  2. 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?

  3. 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?

  4. _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?

  5. 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)?

  6. 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?

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

  8. 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)

  1. 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).
  2. Entry — the park path above, plus the [operator] policy section (→ core.toml, sync-tested), the operatoractions.py registry writer, and the park notification.
  3. Exitbmad-loop confirm, validate checks, docs.
  4. 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):

  1. 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").
  2. 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.
  3. step-01 routing: an awaiting-operator spec is ingested as context, not resumed (same as done).
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecycleenhancementNew feature or requestneeds-designAwaiting a maintainer design decision before code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions