From 215cb99c2793b26bf5e3ae4cdd7166c7a19d5ea0 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 28 Jul 2026 13:44:54 -0700 Subject: [PATCH 1/4] feat(operator): park-record-missing check id + fresh-clone E2E (#356) --- CHANGELOG.md | 3 ++ docs/FEATURES.md | 6 ++-- src/bmad_loop/checks.py | 1 + src/bmad_loop/cli.py | 50 ++++++++++++++++++------------- tests/test_cli.py | 21 +++++++++---- tests/test_engine_worktree.py | 55 +++++++++++++++++++++++++++++++++-- 6 files changed, 105 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb9b3bf..e30471ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ breaking changes may land in a minor release. failed record write never blocks the story — it parks recordless, journaled and reported by `validate` — and a failed commit restores the record as found. Confirming commits the record's deletion together with the spec+board flip, and a legacy-only park still gets that commit. + `validate` reports a board parked with no record under its own id, `operator.park-record-missing`: + no longer the fresh-clone norm, but evidence of a failed record write, a pre-upgrade park, or a + deleted record. - **`bmad-loop confirm` completes a parked story (#335, part 3 of 4).** Once you have carried out the external actions a story owed, `bmad-loop confirm ` walks you through them one at a time diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 6da8d34d..48887751 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -63,8 +63,8 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. A resolved re-drive is pause-free by contract, so there it journals and proceeds. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues. The defer notification names where the attempt survives — in place, the recovery ref the rollback parked it on plus the `git merge --ff-only` line that restores it, flagged as commits-only when the uncommitted snapshot could not be captured (only reachable on a re-drive since #340 — a plain rollback refuses the reset instead); isolated, the kept-failed unit branch plus any earlier rolled-back attempt's ref (named, not offered as a merge — it is not fast-forwardable there). That recovery ref is projected as `preserve_ref` in `status` and `--json`; the unit branch never is (#333). When the recovery itself pauses the run (rollback OFF, or a preserve failure refusing the reset), the defer record still lands before the pause — its notice then points at the ACTION REQUIRED manual-recovery notice instead of naming a ref (#342). - Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story whose acceptance criteria include something no agent can do — buy a domain, publish a DNS record, grant an API key — finishes and **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board advances to `awaiting-operator` (a forward move; nothing regresses), the run continues to the next story, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. A park clears every deterministic gate a `done` story clears (the spec/board pair, the project's verify commands, a non-empty action list) and skips only the review loop, which has nothing in the diff to converge on; a park declaring nothing readable is refused and repaired rather than committed. Parking is notify-only — non-blocking by design, so unlike an escalation it never halts the run. `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` (a green board hiding outstanding work) or `blocked` (halting the whole run). -- Completing a park: `bmad-loop confirm ` walks you through the outstanding actions one at a time, then writes the spec's `## Operator Confirmation` audit section, advances the spec and board to `done`, and commits the pair. Nothing is re-driven — the agent-doable work was committed at park time, and re-running a session would redo finished work while the actions stayed outside the repo; `--reverify` re-runs the project's `[verify]` commands first (for the case that matters: the external action may have changed what the tests see) and a failure blocks the confirmation. The parked stories are indexed in `.bmad-loop/operator-actions.json`, which is **machine-local and never committed** — it is written from inside a story's commit window, where committing it would either shift `HEAD` past the commit the park just stamped or, under worktree isolation, advance the target branch and break an `scm.merge_strategy = "ff"` merge-back. The committed truth is the spec and the board; the index is how `confirm` finds them, so a park is confirmed on the machine that ran it. `validate` warns on drift in both directions (`operator.registry-stale`, `operator.actions-malformed`) and `confirm` refuses a drifted entry rather than flipping a board on the index's word alone. -- A confirmation is resumable. Every write is checked — the spec is read back from disk rather than trusted, so a story is never declared done over a write that did not land — and the index entry is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, advancing the board, dropping the entry and committing, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and it is fence-aware, so an example quoted inside a code block never counts as one). It resumes equally from a board a human already fixed by hand, since that is what the failure message asks for. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) all name the state rather than calling it stale — the remedy inverts, so the check id does too. +- Completing a park: `bmad-loop confirm ` walks you through the outstanding actions one at a time, then writes the spec's `## Operator Confirmation` audit section, advances the spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time, and re-running a session would redo finished work while the actions stayed outside the repo; `--reverify` re-runs the project's `[verify]` commands first (for the case that matters: the external action may have changed what the tests see) and a failure blocks the confirmation. Each park is recorded in a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit — through the worktree merge-back, under every `scm.merge_strategy` — to every clone the commit reaches: a teammate, a fresh clone, or CI can confirm a story parked elsewhere. (The pre-#356 machine-local index `.bmad-loop/operator-actions.json` is still read and pruned on confirm, never written, so an in-flight park from an older version stays confirmable where it was written.) The committed truth is the spec and the board; the record is how `confirm` finds them. `validate` warns on drift in every direction — `operator.registry-stale`, `operator.actions-malformed`, and `operator.park-record-missing` for a board parked with no record, which is no longer the fresh-clone norm but evidence of a failed record write, a pre-upgrade park, or a deleted record — and `confirm` refuses a drifted record rather than flipping a board on the record's word alone. +- A confirmation is resumable. Every write is checked — the spec is read back from disk rather than trusted, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, advancing the board, dropping the entry and committing, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and it is fence-aware, so an example quoted inside a code block never counts as one). It resumes equally from a board a human already fixed by hand, since that is what the failure message asks for. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) all name the state rather than calling it stale — the remedy inverts, so the check id does too. - Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a deterministic verify command whose _environment_ is broken (`sh` reports `rc 126/127`; `cmd` has no such convention, so on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, anything outside `PATHEXT` — is a fault rather than the silent `rc 0` pass it used to be, #302), **or** a dev/review/fix/workflow/sweep session whose pane log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt/cycle and deferring the story as if its code were broken. Re-arm (or a sweep's escalation-resume) restores the budget, so a resume after the outage clears re-drives from a clean slate. Patterns are per-profile (seeded only for `claude`; extend/disable via a project profile overlay). - CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. @@ -194,7 +194,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - `bmad-loop resume ` — continue a paused/interrupted run. - `bmad-loop resolve ` — resolve a CRITICAL escalation, then re-arm + resume (`--story`, `--no-interactive`, `--restore-patch ` for intent-gap patch-restore, `--resume`/`--no-resume`). - `bmad-loop decisions` — answer deferred-work decisions past sweeps left unanswered (`--list` to just show them). `--json` instead emits a stable machine-readable document (schema-versioned; per decision the id, question, context, recommendation and every option's key/label/effect/intent/resolution/bundle-name plus a derived `recommended` flag) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing pending yields a valid empty document. -- `bmad-loop confirm ` — complete a story parked at `awaiting-operator` once you have carried out the external actions it owes: acknowledges each action in turn (`--yes` skips the prompts), writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair. `--list` shows every parked story and what each owes without confirming any (marking one whose confirmation was interrupted as already signed off, not as unconfirmable); `--reverify` re-runs the project's `[verify]` commands first and blocks the confirmation if they fail. Re-running it on an interrupted confirmation finishes that confirmation instead of refusing it. `--json` instead emits a stable machine-readable document (schema-versioned; per parked story the key, actions, spec file, spec/board status, parking commit and run, plus the derived `confirmable` and `resumable` flags, the `confirmation_recorded` reading behind the latter, and a human `drift` reason) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing parked yields a valid empty document. +- `bmad-loop confirm ` — complete a story parked at `awaiting-operator` once you have carried out the external actions it owes: acknowledges each action in turn (`--yes` skips the prompts), writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair. `--list` shows every parked story and what each owes without confirming any (marking one whose confirmation was interrupted as already signed off, not as unconfirmable); `--reverify` re-runs the project's `[verify]` commands first and blocks the confirmation if they fail. Re-running it on an interrupted confirmation finishes that confirmation instead of refusing it. `--json` instead emits a stable machine-readable document (schema-versioned; per parked story the key, actions, spec file, spec/board status, the parking run and the commit that carries the park — derived from the record's own git history, empty when the record is not yet in any commit — plus the derived `confirmable` and `resumable` flags, the `confirmation_recorded` reading behind the latter, and a human `drift` reason) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing parked yields a valid empty document. - `bmad-loop list` (`ls`) — list every run/sweep with its short ref, type, and status. `--json` instead emits a stable machine-readable document (schema-versioned; one entry per run, oldest first: short ref, run id, type, started-at, liveness-aware status, paused stage) per the [contract below](#machine-readable-output---json); an empty runs dir yields a valid empty document. - `bmad-loop status []` — run + sprint summary with per-story token totals, cost-weighted with the raw count alongside. `--json` instead emits a stable machine-readable document (schema-versioned; run state, snapshot `cache_read_weight`, per-story phase/attempt/review-cycle/tokens/commit/defer-reason, plus the additively-added run-level `adapters` — the dev/review/triage adapter the policy snapshot resolves to, `null` on a run predating adapter stamping — and per-story `adapters_used`, the adapter identity actually recorded per role) per the [contract below](#machine-readable-output---json) — the supported surface for scripts; the text output is best-effort. - `bmad-loop diagnose []` (`diag`) — emit a sanitized diagnostic dump of a run/sweep to hand maintainers (histograms, counts, env, file sizes — no code/spec/prompts/paths/PII); a stray pseudonymized identifier is auto-substituted with its alias (disclosed in the report), while PII/secret hits still refuse to emit; defaults to the latest run (`--all`, `--out`, `--max-journal-entries`). `--json` emits the same dump as a stable machine-readable document per the [contract below](#machine-readable-output---json) instead of the markdown report. diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 3181f7cc..bcc8a5e1 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -78,6 +78,7 @@ "operator.registry-stale", "operator.actions-malformed", "operator.confirm-interrupted", + "operator.park-record-missing", "deferred.closes-unknown", "deferred.closes-malformed", "deferred.closes-entry-unreadable", diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index b6859ee1..8ac05062 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -627,22 +627,26 @@ def _spec_closes_deferred(path: Path) -> tuple[tuple[str, ...], str | None]: def _validate_operator_registry( project: Path, paths: bmadconfig.ProjectPaths, report: ValidationReport ) -> None: - """Report drift between the operator-actions index and the committed state it - points at (#335). - - The index is machine-local and never committed (see - :mod:`bmad_loop.operatoractions` for why), so it CAN fall out of step with - the specs and board that are the real record — a story re-driven to done, a - reverted commit, a hand-edited spec, or simply a clone that never had one. - That is only a safe trade if something says so out loud. - - Never a failure, always a warning, both directions. `confirm` already refuses - to act on a drifted entry, so nothing here gates anything; a stale index must - not be able to block a run that would otherwise start. Reported in both - directions because they have opposite remedies: an entry the committed state - has moved past is stale bookkeeping to discard, while a board sitting at - `awaiting-operator` with no entry is an obligation nobody can confirm from - this machine. + """Report drift between the park records and the committed state they point + at (#335, #356). + + A record is committed beside the truth it points at, but it can still + disagree with it — a story re-driven to done, a reverted commit, a + hand-edited spec, a record file mangled in a merge. That is only a safe + trade if something says so out loud. + + Never a failure, always a warning, in every direction. `confirm` already + refuses to act on a drifted entry, so nothing here gates anything; a stale + record must not be able to block a run that would otherwise start. The + directions carry different ids because their remedies differ: an entry the + committed state has moved past is stale bookkeeping to discard + (`registry-stale`), while a board sitting at `awaiting-operator` that no + record claims (`park-record-missing`) is an obligation whose spec nothing + can find. Before #356 that second arm was the fresh-clone NORM and shared + `registry-stale`; now the record travels with the park's own commit, so a + missing one is always evidence of something — a record write that failed at + park time, a park from a version that recorded only machine-locally, a + checkout without the park's branch, or a record deleted without confirming. An INTERRUPTED confirmation gets its own id rather than being reported as stale, because its remedy inverts: re-running `confirm` finishes it. Saying @@ -704,11 +708,15 @@ def _validate_operator_registry( ) if orphans: report.warn( - "operator.registry-stale", - f"the board parks {', '.join(orphans)} at awaiting-operator, but the " - f"operator-actions index has no entry for them — the index is machine-local " - f"and not committed, so `bmad-loop confirm` cannot complete them from here; " - f"confirm them on the machine that ran the story, or finish them by hand", + "operator.park-record-missing", + f"the board parks {', '.join(orphans)} at awaiting-operator, but no park " + f"record under {operatoractions.RECORDS_REL.as_posix()}/ claims them, so " + f"`bmad-loop confirm` cannot find their specs from this checkout. The record " + f"travels with the park's own commit — a missing one means the record write " + f"failed at park time (journaled as operator-index-failed), the park predates " + f"committed records (confirm it on the machine that ran it), this checkout " + f"lacks the branch carrying the park commit (pull it), or the record was " + f"deleted without confirming; otherwise finish the story by hand", {"story_keys": orphans}, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 449df185..c1d2a3e4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5071,15 +5071,26 @@ def test_validate_warns_on_a_stale_index_entry(project, capsys): assert "1-1-a" in findings["operator.registry-stale"]["message"] -def test_validate_warns_when_the_board_parks_a_story_the_index_lost(project, capsys): - """The opposite direction, and the opposite remedy: an obligation nobody can - confirm from this machine.""" +def test_validate_reports_a_parked_board_with_no_record_under_its_own_id(project, capsys): + """The opposite direction under its own id (#356). Before the committed + records this was the fresh-clone NORM and shared `registry-stale`; now the + record travels with the park's own commit, so a board at `awaiting-operator` + that no record claims is always evidence of something — a failed record + write, a pre-upgrade park, a checkout without the park's branch, a deleted + record — and its remedy differs from a stale entry's discard-it, which is + exactly where `checks` splits ids. + + Ablation: report the orphan arm under `operator.registry-stale` again and + this fails on the id lookup.""" install_bmad_config(project) write_sprint(project, {"epic-1": "in-progress", "1-1-a": "awaiting-operator"}) findings = _validate_findings(project, capsys, rc=1) - stale = findings["operator.registry-stale"] - assert "no entry" in stale["message"] and stale["detail"]["story_keys"] == ["1-1-a"] + missing = findings["operator.park-record-missing"] + assert missing["severity"] == "warning" + assert "no park record" in missing["message"] + assert missing["detail"]["story_keys"] == ["1-1-a"] + assert "operator.registry-stale" not in findings def test_validate_warns_on_a_park_declaring_nothing_readable(project, capsys): diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index a9266cfb..d9d940fb 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -211,14 +211,23 @@ def test_worktree_happy_path_merges_to_target(project): assert "worktree-teardown-degraded" not in kinds -def test_worktree_parked_unit_merges_like_a_done_one(project): +@pytest.mark.parametrize("merge_strategy", ["merge", "ff"]) +def test_worktree_parked_unit_merges_like_a_done_one(project, merge_strategy): """`integrate_unit` branches on DONE-vs-everything-else, and a park is the one non-DONE terminal that CARRIES A COMMIT. Left on the else arm the unit is torn down as failed and finished work is stranded on a deleted branch — over an obligation that lives outside the repo entirely. + The `ff` leg is the #356 acceptance regression guard: the committed park + record must never cost a fast-forward merge-back — the failure every + commit-something-on-the-target sketch died on, and the reason the record is + written inside the unit's own commit window instead. + Ablation: narrow the merge test back to `== Phase.DONE` and this fails with - the story's change absent from the target branch.""" + the story's change absent from the target branch. For the record's placement, + root `_write_park_record` at `self.paths.project` instead of the workspace + and both legs fail on the ls-tree assertion — the record sits untracked at + the target instead of riding the merge.""" commit_sprint(project, {"1-1-a": "ready-for-dev"}) actions = ["publish the _acme-challenge TXT record"] engine, _ = make_engine( @@ -228,6 +237,7 @@ def test_worktree_parked_unit_merges_like_a_done_one(project): project, "1-1-a", final_status="awaiting-operator", operator_actions=actions ) ], + policy=wt_policy(merge_strategy=merge_strategy), ) summary = engine.run() @@ -255,6 +265,47 @@ def test_worktree_parked_unit_merges_like_a_done_one(project): assert story.commit # derived from the record's history on the target branch +def test_a_parked_story_confirms_on_a_fresh_clone(project, tmp_path): + """The #356 acceptance criterion end to end: a story parks under worktree + isolation, its record rides the unit's commit through the merge-back, and a + clone that never ran the orchestrator — no run state, no journal, nothing + machine-local — lists and confirms it from the committed files alone. + + Ablation: delete the `_write_park_record` call in `_finalize_commit_phase` + and this fails at the confirm — the clone sees a parked board it cannot + resolve a spec for.""" + from conftest import install_bmad_config + + from bmad_loop import bmadconfig, cli, operatoractions, sprintstatus + + install_bmad_config(project) # `confirm` resolves the clone's paths from it + commit_sprint(project, {"1-1-a": "ready-for-dev"}) + actions = ["publish the _acme-challenge TXT record"] + engine, _ = make_engine( + project, + [ + wt_dev_effect( + project, "1-1-a", final_status="awaiting-operator", operator_actions=actions + ) + ], + ) + summary = engine.run() + assert summary.awaiting_operator == 1 + + clone = tmp_path / "fresh-clone" + git(tmp_path, "clone", "-q", str(project.project), str(clone)) + # a clone carries no local identity; the confirm commit needs one + git(clone, "config", "user.email", "operator@test") + git(clone, "config", "user.name", "operator") + + assert cli.main(["confirm", "--project", str(clone), "1-1-a", "--yes"]) == 0 + clone_paths = bmadconfig.load_paths(clone) + assert sprintstatus.story_status(clone_paths.sprint_status, "1-1-a") == "done" + assert "confirm 1-1-a" in git(clone, "log", "-1", "--format=%s") + assert operatoractions.load(clone) == {} # the record's deletion rode the commit + assert worktree_clean(clone) + + def test_worktree_run_dir_is_outside_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) engine, _ = make_engine( From be4a2cf7e8b31da5fb51be6966ed4cf881156fcb Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 28 Jul 2026 14:51:15 -0700 Subject: [PATCH 2/4] docs(operator): finish the index -> park-entry message sweep --- CHANGELOG.md | 4 ++-- src/bmad_loop/cli.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e30471ea..ffaa87e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ breaking changes may land in a minor release. the spec is read BACK from disk rather than trusted, so a story is never declared done over a write that did not land. A confirmation interrupted between its spec writes and its board write (the board file in a shape its line writer cannot rewrite, or an IO failure) leaves a signed-off - spec at `done` with the index entry still pointing at it — re-running `confirm` now **finishes** + spec at `done` with the park entry still pointing at it — re-running `confirm` now **finishes** that instead of refusing it as stale drift: it advances the board, drops the entry and commits, with no second prompt and no second audit section. It resumes equally from a board a human already fixed by hand, since that is what the failure message asks them to do. `--list`, `--json` @@ -67,7 +67,7 @@ breaking changes may land in a minor release. committed. Under worktree isolation the unit merges like a `done` one. `[operator] enabled = false` restores the old two-outcome behavior. - `bmad-loop confirm` and the project-level index it reads arrived in part 3, above. + `bmad-loop confirm` and the park entries it reads arrived in part 3, above. - **`awaiting-operator` vocabulary, no writer yet (#335, part 1 of 4).** Names, at every layer, the state a story reaches when its agent-doable work is finished and committed but its acceptance diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 8ac05062..1afd313f 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -680,7 +680,7 @@ def _validate_operator_registry( report.warn( "operator.confirm-interrupted", f"{story.story_key} was confirmed but the board was never advanced: its " - f"spec is signed off and reads done while the index still lists it. " + f"spec is signed off and reads done while the park entry still lists it. " f"`bmad-loop confirm {story.story_key}` finishes it without asking you to " f"acknowledge anything again", {"story_key": story.story_key, "board_status": story.board_status}, @@ -691,7 +691,7 @@ def _validate_operator_registry( if drift is not None: report.warn( "operator.registry-stale", - f"the operator-actions index lists {story.story_key} as parked, but " + f"the park entry lists {story.story_key} as parked, but " f"{drift} — the entry is stale and `bmad-loop confirm " f"{story.story_key}` will refuse it", {"story_key": story.story_key, "drift": drift}, @@ -1661,7 +1661,7 @@ def cmd_confirm(args: argparse.Namespace) -> int: drift = story.drift() if drift is not None: print( - f"error: refusing to confirm {key}: {drift}. The index disagrees with the " + f"error: refusing to confirm {key}: {drift}. The park entry disagrees with the " f"committed state; `bmad-loop validate` reports the same drift. Nothing was " f"changed.", file=sys.stderr, @@ -1724,7 +1724,7 @@ def _resume_confirmation( assert spec is not None # resumable requires a spec that read back as done print( f"{story.story_key} was already confirmed — its spec is signed off and reads " - f"done, but the board and the index entry were left behind. Finishing that; " + f"done, but the board and the park entry were left behind. Finishing that; " f"you will not be asked to acknowledge anything again.\n" ) if args.reverify: @@ -1753,11 +1753,11 @@ def _apply_confirmation( story: operatoractions.ParkedStory, spec: Path, ) -> int: - """Write the confirmation: spec audit section, spec status, board, index. + """Write the confirmation: spec audit section, spec status, board, park entry. Ordered so a failure part-way is recoverable rather than stranded. The audit section goes first because it is the only record of what happened outside the - repo and it does not change any gate. The index entry is dropped LAST, so + repo and it does not change any gate. The park entry is dropped LAST, so anything that raises before it leaves the story findable and the command re-runnable — see `ParkedStory.resumable` for the state that leaves behind. @@ -1787,7 +1787,7 @@ def _apply_confirmation( # left to write a status onto either. print( f"error: {spec} disappeared before {story.story_key} could be confirmed — " - f"nothing was changed and the index entry has been left in place.", + f"nothing was changed and the park entry has been left in place.", file=sys.stderr, ) return 1 @@ -1797,7 +1797,7 @@ def _apply_confirmation( print( f"error: {spec} carries a status this cannot rewrite, so {story.story_key} " f"was NOT confirmed: {e}. The audit section was appended; the board and the " - f"index entry are untouched, so re-run `bmad-loop confirm {story.story_key}` " + f"park entry are untouched, so re-run `bmad-loop confirm {story.story_key}` " f"once the frontmatter is repaired. {_SECOND_SECTION_NOTE}", file=sys.stderr, ) @@ -1811,7 +1811,7 @@ def _apply_confirmation( if frontmatter.status_of(frontmatter.read_frontmatter(spec)) != "done": print( f"error: {spec} still does not read status: done, so {story.story_key} was " - f"NOT confirmed. The audit section was appended; the board and the index " + f"NOT confirmed. The audit section was appended; the board and the park " f"entry are untouched. {_SECOND_SECTION_NOTE}", file=sys.stderr, ) @@ -1843,7 +1843,7 @@ def _land_confirmation( f"error: {spec} was updated but {paths.sprint_status} did not advance " f"{story.story_key} to done (it reads {landed!r}). Fix the board by hand, then " f"re-run `bmad-loop confirm {story.story_key}` — the spec is already correct " - f"and signed off, and the index entry has been left in place, so the re-run " + f"and signed off, and the park entry has been left in place, so the re-run " f"finishes what is left without asking you to acknowledge anything twice.", file=sys.stderr, ) From d255a73ace0dfca79eb775ef36ed4342069724a1 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 28 Jul 2026 14:59:43 -0700 Subject: [PATCH 3/4] docs(operator): list the unpulled-branch cause with the other three --- CHANGELOG.md | 4 ++-- docs/FEATURES.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffaa87e4..cbf014fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ breaking changes may land in a minor release. `validate` — and a failed commit restores the record as found. Confirming commits the record's deletion together with the spec+board flip, and a legacy-only park still gets that commit. `validate` reports a board parked with no record under its own id, `operator.park-record-missing`: - no longer the fresh-clone norm, but evidence of a failed record write, a pre-upgrade park, or a - deleted record. + no longer the fresh-clone norm, but evidence of a failed record write, a pre-upgrade park, a + checkout that lacks the branch carrying the park commit (pull it), or a deleted record. - **`bmad-loop confirm` completes a parked story (#335, part 3 of 4).** Once you have carried out the external actions a story owed, `bmad-loop confirm ` walks you through them one at a time diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 48887751..1e0e7596 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -63,7 +63,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. A resolved re-drive is pause-free by contract, so there it journals and proceeds. `scm.preserve_keep` (default 20) bounds retention of both ref families. - Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues. The defer notification names where the attempt survives — in place, the recovery ref the rollback parked it on plus the `git merge --ff-only` line that restores it, flagged as commits-only when the uncommitted snapshot could not be captured (only reachable on a re-drive since #340 — a plain rollback refuses the reset instead); isolated, the kept-failed unit branch plus any earlier rolled-back attempt's ref (named, not offered as a merge — it is not fast-forwardable there). That recovery ref is projected as `preserve_ref` in `status` and `--json`; the unit branch never is (#333). When the recovery itself pauses the run (rollback OFF, or a preserve failure refusing the reset), the defer record still lands before the pause — its notice then points at the ACTION REQUIRED manual-recovery notice instead of naming a ref (#342). - Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story whose acceptance criteria include something no agent can do — buy a domain, publish a DNS record, grant an API key — finishes and **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board advances to `awaiting-operator` (a forward move; nothing regresses), the run continues to the next story, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. A park clears every deterministic gate a `done` story clears (the spec/board pair, the project's verify commands, a non-empty action list) and skips only the review loop, which has nothing in the diff to converge on; a park declaring nothing readable is refused and repaired rather than committed. Parking is notify-only — non-blocking by design, so unlike an escalation it never halts the run. `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` (a green board hiding outstanding work) or `blocked` (halting the whole run). -- Completing a park: `bmad-loop confirm ` walks you through the outstanding actions one at a time, then writes the spec's `## Operator Confirmation` audit section, advances the spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time, and re-running a session would redo finished work while the actions stayed outside the repo; `--reverify` re-runs the project's `[verify]` commands first (for the case that matters: the external action may have changed what the tests see) and a failure blocks the confirmation. Each park is recorded in a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit — through the worktree merge-back, under every `scm.merge_strategy` — to every clone the commit reaches: a teammate, a fresh clone, or CI can confirm a story parked elsewhere. (The pre-#356 machine-local index `.bmad-loop/operator-actions.json` is still read and pruned on confirm, never written, so an in-flight park from an older version stays confirmable where it was written.) The committed truth is the spec and the board; the record is how `confirm` finds them. `validate` warns on drift in every direction — `operator.registry-stale`, `operator.actions-malformed`, and `operator.park-record-missing` for a board parked with no record, which is no longer the fresh-clone norm but evidence of a failed record write, a pre-upgrade park, or a deleted record — and `confirm` refuses a drifted record rather than flipping a board on the record's word alone. +- Completing a park: `bmad-loop confirm ` walks you through the outstanding actions one at a time, then writes the spec's `## Operator Confirmation` audit section, advances the spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time, and re-running a session would redo finished work while the actions stayed outside the repo; `--reverify` re-runs the project's `[verify]` commands first (for the case that matters: the external action may have changed what the tests see) and a failure blocks the confirmation. Each park is recorded in a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit — through the worktree merge-back, under every `scm.merge_strategy` — to every clone the commit reaches: a teammate, a fresh clone, or CI can confirm a story parked elsewhere. (The pre-#356 machine-local index `.bmad-loop/operator-actions.json` is still read and pruned on confirm, never written, so an in-flight park from an older version stays confirmable where it was written.) The committed truth is the spec and the board; the record is how `confirm` finds them. `validate` warns on drift in every direction — `operator.registry-stale`, `operator.actions-malformed`, and `operator.park-record-missing` for a board parked with no record, which is no longer the fresh-clone norm but evidence of a failed record write, a pre-upgrade park, a checkout that lacks the branch carrying the park commit (pull it), or a deleted record — and `confirm` refuses a drifted record rather than flipping a board on the record's word alone. - A confirmation is resumable. Every write is checked — the spec is read back from disk rather than trusted, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, advancing the board, dropping the entry and committing, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and it is fence-aware, so an example quoted inside a code block never counts as one). It resumes equally from a board a human already fixed by hand, since that is what the failure message asks for. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) all name the state rather than calling it stale — the remedy inverts, so the check id does too. - Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. - Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a deterministic verify command whose _environment_ is broken (`sh` reports `rc 126/127`; `cmd` has no such convention, so on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, anything outside `PATHEXT` — is a fault rather than the silent `rc 0` pass it used to be, #302), **or** a dev/review/fix/workflow/sweep session whose pane log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt/cycle and deferring the story as if its code were broken. Re-arm (or a sweep's escalation-resume) restores the budget, so a resume after the outage clears re-drives from a clean slate. Patterns are per-profile (seeded only for `claude`; extend/disable via a project profile overlay). From 76c8da605ac9333c60ed8e8e0d66ddca072543ce Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 28 Jul 2026 15:50:44 -0700 Subject: [PATCH 4/4] docs(operator): list every operator check id in the module docstring --- src/bmad_loop/operatoractions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bmad_loop/operatoractions.py b/src/bmad_loop/operatoractions.py index 19a7c78a..cf9490c3 100644 --- a/src/bmad_loop/operatoractions.py +++ b/src/bmad_loop/operatoractions.py @@ -43,9 +43,11 @@ Because a record can still drift from the committed truth it points at (a hand-edited spec, a ``git revert``, a story re-driven to ``done``), ``validate`` -carries ``operator.registry-stale`` and ``operator.actions-malformed``. Both are -warnings: ``confirm`` refuses drifted entries itself, so nothing gates on the -record. +carries ``operator.registry-stale``, ``operator.actions-malformed`` and +``operator.confirm-interrupted``, and reports a board parked at +``awaiting-operator`` that no record claims as ``operator.park-record-missing`` +(#356) — ids split where the remedy does. All are warnings: ``confirm`` refuses +drifted entries itself, so nothing gates on the record. """ from __future__ import annotations