When a story is deferred after review-budget exhaustion, the engine parks the attempt's work on a git ref and then throws the ref away. Nothing the operator can see — the notification, bmad-loop status, and status --json — ever names it. The reporter of the umbrella field report recovered their work by running git log --all and guessing.
Evidence (validated at 3c68153)
The ref is created, named well, and journaled:
- Committed attempt work → branch
attempt-preserve/<run-id-slug>-<head8> — recovery_flow.py:317-324, via verify.preserve_commits (verify.py:266), which returns the ref.
- Dirty worktree →
refs/attempt-preserve-dirty/<slug>-<baseline8>-<attempt> — recovery_flow.py:360.
- Retention pruned to
scm.preserve_keep (default 20).
- Recorded only as journal events
attempt-commits-preserved / attempt-worktree-preserved — recovery_flow.py:335-337, :374.
Then it is dropped:
preserve_attempt_commits returns None (recovery_flow.py:292) — the ref never reaches task or run state.
- No
src/ consumer of those journal events. Not in diagnostics.py, not in the TUI.
- Status text prints only
defer_reason / commit_sha — cli.py:1581-1585.
--json projection has no field — documents.py:227-239. cmd_status never opens journal.jsonl.
The defer notification is also thinner than escalation's. _defer does call gates.notify on both arms (engine.py:3002 worktree, :3022 in-place) — the message body is just the bare reason, with no action tail. Contrast escalation, which composes f"{reason} — resolve, then \bmad-loop resume {run_id}`" (engine.py:3079-3084). Prior art for a good recovery message already exists: recovery_flow.py:409-459manual-recovery instructions do "your work is at X, rungit -C …`".
Zero test coverage pins that _defer / _escalate notify at all (grep ATTENTION tests/ → gates unit tests + adapter budget-trip only), so this is regression-invisible.
Fix outline
- New
StoryTask.preserve_ref: str | None (model.py after :176; additive to_dict/from_dict, drop-in like restore_patch).
- Set it inside
RecoveryFlow (its methods already receive the task): clear to None at the top of the auto-recover arm (recovery_flow.py:165), assign in preserve_attempt_commits (:335) and preserve_attempt_worktree (:373). Dirty (last writer) wins — the dirty snapshot is parented at attempt HEAD (verify.py:446-449), so it subsumes the commits branch and one uniform git merge --ff-only <ref> recovers either case.
_defer in-place arm: notify message = reason + — attempt work parked at `<ref>`; recover with `git -C "<root>" merge --ff-only <ref>` (engine.py:3022-3027), plus a preserve_ref= field on the story-deferred journal entry (:3021).
- Isolated (worktree) arm: when
scm.keep_failed and task.branch, append — failed work kept on branch `<branch>`. Do not set preserve_ref to the unit branch.
- Projections:
documents.py:227-239 adds preserve_ref; cli.py:1581 text; README.md:553/:465; docs/FEATURES.md:62.
- Escalation path out of scope (no rollback there). No policy /
core.toml changes. Not cleared on DONE — it stays as a mid-retry rollback breadcrumb.
Tests
Extend test_recovery_flow.py:269/:297/:336 plus a new both-families test (assert the dirty ref and git merge-base --is-ancestor subsumption); test_engine.py:3204 defer test asserting ATTENTION content; test_engine_worktree.py:297/:320 branch naming; test_model.py round-trip; test_cli.py:865/:1076 json + text.
Two ablations required before trusting the suite (repo rule): (1) delete the None-clear → the stale-ref test must fail; (2) make the message tail unconditional → the bare-reason-when-no-ref test must fail.
Edge cases already analysed: replay re-defer re-parks under the same names (branch -f, attempt-keyed dirty ref); the pruned-ref caveat is documented, never validated at status time (status must not run git); message truncation rides the message, not the title.
Acceptance
- The defer notification names the ref and gives a runnable
git merge --ff-only command.
bmad-loop status and status --json expose preserve_ref additively — --json schema stays v1.
Part of the 0.9.0 field-report umbrella (linked parent).
When a story is deferred after review-budget exhaustion, the engine parks the attempt's work on a git ref and then throws the ref away. Nothing the operator can see — the notification,
bmad-loop status, andstatus --json— ever names it. The reporter of the umbrella field report recovered their work by runninggit log --alland guessing.Evidence (validated at
3c68153)The ref is created, named well, and journaled:
attempt-preserve/<run-id-slug>-<head8>—recovery_flow.py:317-324, viaverify.preserve_commits(verify.py:266), which returns the ref.refs/attempt-preserve-dirty/<slug>-<baseline8>-<attempt>—recovery_flow.py:360.scm.preserve_keep(default 20).attempt-commits-preserved/attempt-worktree-preserved—recovery_flow.py:335-337,:374.Then it is dropped:
preserve_attempt_commitsreturnsNone(recovery_flow.py:292) — the ref never reaches task or run state.src/consumer of those journal events. Not indiagnostics.py, not in the TUI.defer_reason/commit_sha—cli.py:1581-1585.--jsonprojection has no field —documents.py:227-239.cmd_statusnever opensjournal.jsonl.The defer notification is also thinner than escalation's.
_deferdoes callgates.notifyon both arms (engine.py:3002worktree,:3022in-place) — the message body is just the bare reason, with no action tail. Contrast escalation, which composesf"{reason} — resolve, then \bmad-loop resume {run_id}`"(engine.py:3079-3084). Prior art for a good recovery message already exists:recovery_flow.py:409-459manual-recovery instructions do "your work is at X, rungit -C …`".Zero test coverage pins that
_defer/_escalatenotify at all (grep ATTENTION tests/→ gates unit tests + adapter budget-trip only), so this is regression-invisible.Fix outline
StoryTask.preserve_ref: str | None(model.pyafter:176; additiveto_dict/from_dict, drop-in likerestore_patch).RecoveryFlow(its methods already receive the task): clear toNoneat the top of the auto-recover arm (recovery_flow.py:165), assign inpreserve_attempt_commits(:335) andpreserve_attempt_worktree(:373). Dirty (last writer) wins — the dirty snapshot is parented at attempt HEAD (verify.py:446-449), so it subsumes the commits branch and one uniformgit merge --ff-only <ref>recovers either case._deferin-place arm: notify message = reason +— attempt work parked at `<ref>`; recover with `git -C "<root>" merge --ff-only <ref>`(engine.py:3022-3027), plus apreserve_ref=field on thestory-deferredjournal entry (:3021).scm.keep_failed and task.branch, append— failed work kept on branch `<branch>`. Do not setpreserve_refto the unit branch.documents.py:227-239addspreserve_ref;cli.py:1581text;README.md:553/:465;docs/FEATURES.md:62.core.tomlchanges. Not cleared on DONE — it stays as a mid-retry rollback breadcrumb.Tests
Extend
test_recovery_flow.py:269/:297/:336plus a new both-families test (assert the dirty ref andgit merge-base --is-ancestorsubsumption);test_engine.py:3204defer test asserting ATTENTION content;test_engine_worktree.py:297/:320branch naming;test_model.pyround-trip;test_cli.py:865/:1076json + text.Two ablations required before trusting the suite (repo rule): (1) delete the
None-clear → the stale-ref test must fail; (2) make the message tail unconditional → the bare-reason-when-no-ref test must fail.Edge cases already analysed: replay re-defer re-parks under the same names (
branch -f, attempt-keyed dirty ref); the pruned-ref caveat is documented, never validated at status time (status must not run git); message truncation rides the message, not the title.Acceptance
git merge --ff-onlycommand.bmad-loop statusandstatus --jsonexposepreserve_refadditively —--jsonschema stays v1.Part of the 0.9.0 field-report umbrella (linked parent).