Skip to content

Latest commit

 

History

History
790 lines (669 loc) · 275 KB

File metadata and controls

790 lines (669 loc) · 275 KB

bmad-loop — Features & Functionality

For BMAD users who have run bmad-sprint-planning and have a sprint-status.yaml full of ready-for-dev stories. This is what the tool actually does and the problem each capability addresses.

See README.md for the narrative overview and setup-guide.md for installation.


Capability matrix (feature → problem addressed)

Capability What it does Problem it addresses
Deterministic control loop Story selection, retries, gates, completion checks run in plain Python LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow
Dual planning pipelines Same loop from either sprint-status.yaml (sprint mode, default) or a typed stories.yaml dispatched by folder+id (stories mode, opt-in) Sprint boards need bmad-sprint-planning; a bmad-spec Story Breakdown has no board
Per-story human checkpoints Stories-mode spec_checkpoint pauses to review the plan before code; done_checkpoint pauses after the commit; both independent, both surfaced in the TUI Coarse run-global gates can't ask for a plan review on this story only
Trust-nothing verification Checks on-disk artifacts (spec status, canonical baseline validity, proof after the accepted baseline, sprint sync) + runs your test/lint commands before commit Agents claim success without working code; broken builds slip through
Fresh-context adversarial review Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) Self-review anchoring bias; implementer marks own work correct
Hook-based transport Coding-agent hooks write structured event files; skills write result.json Brittle terminal pane-scraping
Resumable state machine Every run is on-disk state, resumable after gate/escalation/crash Long unattended runs lost to interruptions
Plateau-defer Stuck stories are skipped, stashed, and the run continues One unconvergeable story blocking a whole sprint
Human-gated stories (awaiting-operator) A story owing external actions only a human can take commits its agent-doable work, records what is owed, and parks; bmad-loop confirm completes it later Work needing a domain purchase or a DNS record had no honest terminal state — done hid it behind a green board, blocked halted the run
Typed escalations + resolve workflow CRITICAL pauses + notifies; interactive resolve agent re-arms the story Ambiguous specs silently producing wrong code
Deferred-work sweeps Triages an append-only ledger against real code, bundles + executes Split-off goals and review findings get lost
Multi-CLI adapter + profiles Generic driver runs claude/codex/gemini/copilot/cursor/antigravity/opencode; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux + experimental native-Windows psmux bundled; external backends via entry points) Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport
Cost-weighted token budgets Mid-session per-session guard (warn/enforce with a wrap-up grace, sampled every ~30s) plus the advisory per-story cap; both count cache reads at ~0.1x; every display leads with the weighted total and names both units A runaway-but-busy session was bounded only by the wall clock; naive token caps misjudge real cost (cache reads dominate)
Non-invasive skill forks Drives its own bmad-loop-* skill forks; your BMAD install is never modified, and sprint-status.yaml is the one board it writes while a run is in flight — the sessions it dispatches never do Modifying a user's standard BMAD install
Read-only TUI + launcher Live dashboard over run-dir artifacts; launches detached runs No visibility into what an unattended run is doing
Git worktree isolation (opt-in) Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection A long unattended run mutating the working tree you're actively using

Full feature list

Core orchestration loop

  • Automated per-story pipeline: dev → verify → review → verify → commit, end-to-end, no human in the loop.
  • Deterministic control flow in plain Python — story selection, retry budgets, gate checks, and completion checks are code, not an LLM session.
  • Owns sprint-status.yaml, the single source of truth: bmad-sprint-planning generates it, and while a run is in flight the orchestrator is its sole writer (sprintstatus.advance — idempotent, never-regress) while the dev and review sessions it dispatches are told never to write or revert it; your own BMAD skill runs still edit the board outside a run. Selects the next ready-for-dev story; advances by epic/story.
  • Two orchestrator processes can no longer interleave a board advance (#286/#469): sprintstatus.advance holds an advisory cross-process lock across every read that decides the published bytes and across the write itself, so the never-regress decision and the bytes that decision is applied to can no longer be separated by somebody else's write. Being the board's sole write path was never mutual exclusion on its own — a second bmad-loop process runs that same sole writer. Readers stay lock-free, a board that does not exist is still reported missing without a lock being created at all, and a lock that cannot be taken fails the advance on the channel that already carries its errors rather than rewriting the board unserialized. That scope is deliberately narrower than every read the call makes: an advance that would write nothing — an absent row, or a row already at or past target — is answered from one advisory pre-lock read and takes no lock at all (#736), so an idempotent replay such as bmad-loop confirm against a story the board already records as done cannot fail on contention, or on a state root it never needed. The probe runs the same never-regress predicate the locked body applies, so it cannot answer "no write" where the writer would write; every other answer, and any fault while probing, falls through to the hold, which re-reads and decides authoritatively. Same sidecar mechanism as the deferred-work ledger, described under Deferred-work sweeps.
  • Scoping flags: --epic N, --story KEY, --max-stories N, --dry-run (prints the plan, spawns nothing).

Spec + implementation (dev stage)

  • Drives the upstream dev primitive (unmodified) in a fresh tmux session — bmad-build-auto, or bmad-dev-auto on pre-rename releases, resolved from disk per skill tree and invoked under the name it resolves to, so either era runs with no config edit (the legacy name is accepted only when marker-complete, which refuses the forwarding shim the rename leaves behind): it plans a 1.5–4k-token spec, auto-approves it, implements, and self-finalizes the spec; the orchestrator syncs sprint-status and synthesizes result.json from the spec the skill leaves on disk.
  • Deterministic missing-marker catch + repair (#276): the review HALT intermittently finalizes a spec's frontmatter to a terminal status: without appending the ## Auto Run Result section the harvest scan keys on, which once livelocked a finished story into a DEFER-drop (#224). Four mechanisms catch and fix that without ever mutating the launch frontmatter (load-bearing skill routing): a launch-state content hash — a candidate still byte-identical to the spec as it stood at launch is provably untouched by this session, so synthesis is refused — mid-session observation of the spec leaving its launch status (which outranks the hash), artifact repair that appends the owed marker, and one contract nudge per session (limits.dev_contract_nudge, default on).
  • Spec-only contract between stages — review consumes the frozen spec, not the dev session's context.

Verification (trust-nothing gate)

  • After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park this attempt newly elected — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's HEAD; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; [scm] isolation = "worktree" is the provenance-preserving mode.
  • Runs your commands ([verify].commands, e.g. pytest -q, ruff check .) in the git root the code lives in (repo_root): your project dir by default, the mounted per-unit worktree under [scm] isolation = "worktree", and an explicit repo_root: when you set one under isolation = "none". The gate's own artifact READS — the spec's frontmatter, the sprint board, the deferred-work ledger — stay project-rooted either way (#695). What follows the code, besides the command cwd, is every git question the gate asks about it: the recorded baseline is written in the git root, so the commit-identity lookup, both ancestry checks and the non-empty-diff probe are all asked there, and the pathspecs they exclude are spelled relative to that same root (#716). Anchoring those on the project dir meant a correct attempt could be refused forever under an explicit repo_root:. A broken build never reaches review or commit.

Adversarial review (review stage)

  • The follow-up review is a re-invocation of the dev primitive on the done spec — a fresh-context session with no anchoring bias from the implementer (BMAD-METHOD#2508 routes a done spec to a fresh step-04 review pass), so there is no separate review skill.
  • Parallel adversarial layers resolved from the skill's customize.toml (defaults: Blind Hunter (Adversarial-General back when the layer named the standalone skill), Edge-Case-Hunter, Verification-Gap — the third added by BMAD-METHOD#2550 — and the inline Intent Alignment Auditor added by #2560) → verify findings against code → triage → auto-apply patches → log → defer ambiguity → commit. Which skills a layer invokes is a property of the installed primitive, not a catalog pinned here, so bmad-loop validate derives the prerequisite set per tree and both topologies run unchanged: on pre-6.11 sources the hunter layers hand off to upstream bmad-review* skills and those are checked for as bmm prerequisites; on 6.11 sources every layer is a self-contained prompt — the edge-case-hunter and verification-gap layers read the primitive's own review-prompts/*.md, the blind-hunter and intent-alignment ones carry their prompt inline — so no review skill is required at all.
  • Bounded review loop (limits.max_review_cycles, default 3 cycles); done when the pass finishes done and no longer recommends a follow-up. A second guard, limits.max_followup_reviews (default 1), damps the structurally non-convergent case: a finalized pass that keeps recommending its own follow-up is honored only this many times, after which the round converges (verify + commit) and the lingering recommendation is journaled — review-followup-damped, carrying the cap and whether the re-review cap fired with it — instead of burning cycles to the hard cap. 0 never honors a pass's own recommendation. No ledger entry is filed on this path or on plain budget exhaustion (review-budget-committed): the DW-55/64/90 class showed such rows re-litigate a converged story's review rather than record work anyone chose to defer, and a follow-up that matters resurfaces through review of later work, not through a process ticket. (The one path that still files is the review timeout salvage, under its own review-timeout-salvage origin.) (Upstream BMAD-METHOD#2580 has since made the flag convergent by construction — a severity-weighted score over the pass's patched findings rather than a judgment — so the damping guard is now belt-and-suspenders; it stays as the orchestrator-side bound, which #2580 explicitly leaves to the driver.)
  • Optional ([review].enabled, default true): set false to skip the follow-up review session. The dev pass's own inline review (same layers, in-context) is then the only review and it finalizes the story to done — one session per story instead of two. Verify commands still gate the commit. Applies to story runs and deferred-work sweeps alike.
  • Trigger ([review].trigger, default recommended): when review is enabled, decides when the follow-up pass runs. recommended runs it only when the primitive sets followup_review_recommended on a done spec (it self-reviews inline and computes the flag from a severity-weighted score over the final pass's patched findings — flag introduced by BMAD-METHOD#2505, scoring by #2580). always runs it on every story (pre-0.7.0 behavior).

Failure handling & resilience

  • CRITICAL session reasons are composed uniformly for dev, review, fix, migration, and triage. Their complete detail remains in session results, state.json, journal.jsonl, and status --json; ATTENTION, text status, summaries, the TUI pause header, and its resume dialog cap only the displayed copy at 2,000 characters, name journal.jsonl as the complete source, and separately point to the originating spec as a recovery trail when one exists. A blocked generic-dev escalation carries its spec path through the pause, so an intent-gap notice leads directly to the Auto Run Result that names any saved recovery patch.

  • Bounded dev retries (default 2): verify-failures keep the tree and feed the failing output to the next session via --feedback; other failures roll back to baseline.

  • Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode busy/retry status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification.

  • 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. Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. scm.preserve_keep (default 20) bounds retention of both ref families. A fresh-baseline dev retry after such a rollback is told where that work is parked (#777) — the ref, a git log/git diff against the baseline, and that the work is unverified and every gate must pass fresh — but only when Git confirms the ref still resolves, carries this run's name and descends from this task's baseline, and a dev session of the rolled-back attempt was dispatched — recorded, or cut off mid-session by a hard stop or crash. A pruned ref, a resolve re-drive's reset, or a sweep bundle that adopted another bundle's ids gets no paragraph; commits-only preservation says so. Nothing is replayed onto the tree.

  • Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the git merge --ff-only line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as preserve_ref in status/--json; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342).

  • Stories owing human-only external actions park at awaiting-operator instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) commits everything an agent can, records what is owed in its spec's operator_actions: frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and park_asserted: true in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced ## Auto Run Result marker reporting awaiting-operator; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or operator_actions: while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as park-proof-of-work-skipped; zero_diff reports whether the waived gate would have found non-excluded residue (true means none, false means some, null means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later story-awaiting-operator event. Parking is notify-only and never halts the run; [operator] enabled = false restores the old two-outcome behavior, where such a story could only be done or blocked.

  • A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as plan-halt-proof-of-work-skipped. Its zero_diff uses the same tri-state projection (true = the waived gate found no non-excluded residue, false = it found changes, null = Git could not answer), including the stories manifest/spec and orchestrator-written exclusions the live gate would have used. The record is emitted only after the ready-for-dev artifact verification passes and never substitutes for the independent result.json plan_halt: true marker that authorizes the halt; an absent marker or an earlier gate failure produces no waiver record.

  • Completing a park: bmad-loop confirm <story-key> walks the outstanding actions one at a time, writes the spec's ## Operator Confirmation audit section, advances 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; --reverify re-runs your [verify] commands first and a failure blocks the confirmation. Each park is 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 merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). validate warns on drift in every direction (operator.registry-stale, operator.actions-malformed, operator.park-record-missing), and confirm refuses a drifted record. A park written before #356 lives in the machine-local .bmad-loop/operator-actions.json, which confirm still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it.

  • A confirmation is resumable. Every write is checked — the spec is read back from disk, 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, with no second prompt and no second audit section (the section on disk is the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-done board is idempotent. --list, --json (resumable, confirmation_recorded) and validate (operator.confirm-interrupted) name that state rather than calling it stale.

  • Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to sprint-status.yaml with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and a row at done or awaiting-operator is the orchestrator's own bookkeeping — not a defect to fix, and not proof that the work is verified, deliberately, since the row is written before the deterministic dev verification runs and a repair session opens on a red tree under a done row. Only the review prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to status: blocked with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because blocked halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's review prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — post_dev_phase, post_review_result and pre_commit_gate all fire inside that same window — as its own ## Sprint board section appended after the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the blocked redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all.

  • Typed escalations: CRITICAL pauses the run + notifies (desktop + ATTENTION file); PREFERENCE is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second bmad-loop resolve used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a resolution.json now watermarks the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's context.json. When that filtered list contains entries, the resolve skill presents them newest-first under the existing globally de-duplicated contract. When a new pause precedes any newer recorded escalation and the filtered list is empty, the skill presents paused_reason as the available current-pause evidence and discloses that no newer recorded detail exists; it does not read below the watermark, recover an older artifact entry, or synthesize an escalation object. The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, resolve --no-interactive, and the TUI's Re-arm button all leave it where it stands. Leaving a watermark is not clearing it: a watermark already standing still filters on those paths, which show everything recorded since the last accepted resolution rather than the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the esc-upto column of bmad-loop diagnose's markdown task table, and as escalations_resolved_upto under --json (that is the key to grep in a support bundle), which is what explains a short or empty context.json escalation list on a bug report.

  • A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most max_dev_attempts per story) and has no suppression knob of its own; it follows [notify] like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is reduced to its first line and capped, with a […] marker when it was trimmed, because a Decision.reason routinely carries a verify-output tail that would otherwise spill into ATTENTION and a desktop bubble; the untruncated reason stays in the dev-decision journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices.

  • Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose environment is broken (sh reports rc 126/127; 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, or any extension outside PATHEXT, which cmd hands to the file association and which exits 0 without running anything — is a fault rather than a silent rc 0 pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time OSError counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) or a session whose log matches the profile's env_fault_patterns (an API Error … Connection refused-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: claude seeds three, reproducing only complete error sentences its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured 503 stays prose), so a story that merely writes about a provider error cannot trip them (#507); opencode seeds a provider quota/rate-limit and connection pair (#323), matched against the opencode serve process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its ENV_FAULT_LOG_SUFFIX — the tmux pane capture logs/<task-id>.log, or <task-id>.server.out (the opencode serve process's own stdout) for opencode-http, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay.

  • A session that never did anything pauses instead of retrying (#727). A dev CLI parked on a permission dialog, a login, or a dead-on-arrival window ends stalled/crashed/timeout, and all three used to RETRY while budget lasted — a fresh session into the identical wall, max_dev_attempts burned without a line of work. The tmux adapter now stamps produced_work on every non-completed result: true when a Stop arrived (a turn ended), when there is no pane log to read (opencode-http, "unknown never blocks"), when the pane log changed on a tick more than 30 s (FIRST_FRAME_S) after the wait loop started and before the first stall wake nudge went out, or when a model-side transcript record was appended after its sampled baseline before the first stall wake nudge, or the usage sampler saw nonzero spend before that nudge (signals a misbound pane sink, #254/#217, cannot hide; user/setup-only transcript writes and post-nudge writes do not count); false otherwise. The first frame is excluded because a parked CLI paints exactly once — the #727 capture is a 1,930-byte dialog, which clears the #261 read-back gate's 256-byte floor, so that floor is deliberately not the predicate — and post-nudge growth is excluded because the nudge's Enter confirms the dialog's default and the pane grows with the echo and exit text, the loop's own keystrokes. decide_dev PAUSEs a produced_work=false result ahead of the budget, exactly as an environment fault does (no work produced: dev session <status> (no completed turn or qualifying activity was observed — the CLI may be waiting on a human: a permission prompt, a login, a confirmation; the attempt is not charged)), the env-fault arm outranks it, the #489 lost-session suffix survives inside it, and re-arm resets attempt to 0. The flag rides dev-decision beside session_vanished and, when false, every role's session-end. Two deliberate costs, both toward today's behavior: a CLI slower than 30 s to paint at all retries as before, and a session that woke only after a nudge and then never ended a turn pauses for the operator instead of retrying. The review, fix and sweep deciders are unchanged, and stall_deadline, its pane-growth re-arm and the nudge budget are untouched.

  • An idle session is visible while it sits (#680). A session idling inside a tool call (sleep 590; cat …) keeps its pane log growing through spinner repaints, so the stall re-arm — correctly — never fires and nothing separated it from a working session but session_timeout_min. The adapter now stats the live transcript's (mtime_ns, size) — a baseline the moment the first hook event names it, then on the heartbeat cadence (a stat, never parsed usage, so it works for usage_parser = "none") — stamps the age on heartbeat.json as transcript_idle_s (null before a transcript is known), and — when the run's journal is attached, which the engine does for every adapter it owns — journals one session-idle (task_id, idle_s, since_ts, threshold_s) when the age crosses limits.dev_stall_grace_s and one session-active (task_id, idle_s) when the transcript moves again; a later stretch emits a fresh pair. The threshold is the stall grace on purpose — the event fires exactly when the session would have stalled had its pane not kept repainting, so the two records are directly comparable — and 0 disables the events with no new knob. The TUI's agent line shows the open stretch as · idle <age>. Observability only: nothing bounds the stretch, and the session is neither nudged, stalled nor killed for it. Scope: the notice starts when a hook event names the transcript — SessionStart on the claude, codex, gemini and copilot profiles, so it covers a session's first turn there; antigravity fires no SessionStart and names the transcript only on its Stop, so its first turn is invisible to the notice (the heartbeat's transcript_idle_s stays null until then), and the opencode-http transport has no pane wait loop and emits neither the field nor the events.

  • A session the multiplexer lost says so (#489). Sessions complete on a hook Stop or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or bmad-loop stop, an operator kill-session, a server crash, the host sleeping. Both are crashed, so the retry/defer reason an operator reads said only dev session crashed — pointing at the agent when the host was at fault. The crash verdict now asks whether the session still exists and, when it does not, says so in the reason (… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited), as session_vanished on dev-decision and fix-decision either way, beside the routing each fed, on every role's session-end journal entry when it is true (the convention env_fault already uses there), and as a session-vanished breadcrumb in session-lifecycle.jsonl. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence withdraws, not what it proves: has_session maps every nonzero backend result to False, so a negative lookup is "the backend did not confirm it" rather than proof the session is gone — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped after flushing its result still scores completed and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session.

  • CRITICAL resolution: bmad-loop resolve <run-id> 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. The re-arm advances the story's baseline in the code tree and is honest when it cannot: a failed advance is narrowed to typed git errors, journalled, echoed to stderr, and explicitly NOT followed by a re-stamp it did not earn, so spec and task never silently agree on a stale sha (#640). The re-arm's other warn-only git probe — the one that lists the commits an abandoned attempt left below the re-drive's new baseline — is honest the same way (DW-81): its Git failures journal rearm-commits-probe-failed and echo to the same surfaces, because that probe's silence is otherwise indistinguishable from a clean answer, and the absent warning is the operator's only sign that those commits are now a permanent starting point nothing will revisit. A re-stamp that does overwrite a differing claim records what it replaced, and warns on either leg: the record fires only when the spec claimed a baseline the run never recorded, which is the only remaining trace of a divergence the gate can no longer report. spec_file is persisted relative to a worktree for an isolated task, so every out-of-process reader re-anchors it on the tree the run owns before reading or writing — resolved against the process cwd it named the main checkout's copy of the same story spec, and both writes landed on a file the run never used. The same anchor backs the dashboard's review modals and their replan write, context.json's spec_file, and the paths the pause notifications print. The fields beside it (the sentinel indicator, the stories block) take a DIFFERENT root by design: they resolve against the workspace stories root, not the spec's confinement root, whose out-of-mount arm falls back to the project so a confine_root can always contain the path it validates — borrowing that answer for a READ would look the stories folder up in the main checkout while the dev session answered the worktree. Each names the tree the run owns, so a single surface cannot describe two trees. The dev session's own prompt keeps the relative spelling, because that session runs inside the mount. A spec re-arm still cannot read has its baseline re-stamp skipped rather than silently no-oped (rearm-baseline-restamp-skipped), and a status flip that quietly changed nothing is reported too (rearm-spec-flip-skipped) — though not when the spec was simply already at the target status, which is an ordinary re-arm rather than a failure; neither record depends on the git advance having succeeded. On a spec the re-drive will actually READ, that skip also refuses the re-arm outright: the routing status is what the re-drive runs on, and a spec without one halts the re-driven session on an unrecognized status, so the escalation stays armed for a corrected spec instead of being spent on a session that cannot route. The spec is left byte-identical, down to the stale ## Auto Run Result section. Every other spec keeps warn-and-continue, and the record says which happened (refused) so neither surface prints a refusal's remedy for a re-arm that completed. One a re-arm cannot read at all is warn-and-continue because the failed flip says nothing about what the re-drive will read: the path is worktree-relative and the re-drive mounts a fresh worktree and reads the COMMITTED spec either way. A worktree-local one is warn-and-continue for the sharper version of the same reason — the readable file is the copy that fresh mount destroys, so refusing over it would demand a repair to a file nothing opens, even once the corrected spec is committed and routable. Under worktree isolation those writes land in a worktree the re-drive discards, and the re-driven session reads the COMMITTED spec, so re-arm says so (rearm-spec-write-unreachable) and asks you to commit the corrected spec — but only when the spec really is worktree-local and the committed spec does not already carry the status the re-drive needs, so an isolated re-arm whose correction is already committed says nothing, and neither does one whose spec sits in an artifact directory configured outside the project: those are shared across checkouts rather than rebased onto each worktree, so the flip lands on the one file every re-drive reads. That record also HOLDS the resume: both surfaces re-arm and resume in one gesture, which made its own "before resuming" advice unactionable the moment it printed and then burned the escalation on a session that halts blocked. They now stop after the re-arm — the story stays armed, bmad-loop resume <run-id> picks it up once the correction lands, and --resume does not override it, since the record is written on proof rather than suspicion. Each surface names the HELD record's own remedy rather than one hardcoded literal, because the holding records do not share one. Each remedy also names the status: the re-drive routes on, so a correction that lands byte-correct but still terminal cannot burn the escalation a second time — the sentinel remedy below renders that clause on the same contract, and empty, since its leg has no spec status at all. This one asks you to commit, while its in-place arm — isolation edited to none while the story was escalated, so the re-arm's writes went into the escalated attempt's worktree and the re-drive now reads the main checkout — asks you to correct the spec in the MAIN checkout, where committing is not the remedy and naming a branch would be actively wrong. The advisory warnings do not hold. A pre-planning sentinel gets the same treatment on its own artifacts. It is cleared by deletion rather than a status flip, so there is no spec write to measure — but the correction that stops it recurring is upstream (SPEC.md / stories.yaml, where the resolve skill sends the agent instead of the sentinel), and an isolated re-drive re-plans from the committed tree of a fresh mount. Re-arm now says so (rearm-upstream-write-unreachable), names the folder and the branch, and holds the resume the same way. Narrowed on the same principle: it fires only while the branch the re-drive mounts from does not already hold this checkout's copy of those two files, so a correction already committed there resumes in one gesture, and an in-place re-drive never records at all — it reads the main checkout, which is where the resolve session runs. A failed status flip holds too, on exactly one of its three arms, which is why that one is keyed on the record's flags rather than on its kind. rearm-spec-flip-skipped is journalled with refused = spec_path.is_file() and write_reaches_the_redrive, so the arm that reaches the re-drive without being refused proves the recorded spec path is not a readable file here — the re-drive reads that same path and finds no spec there to route on, futile on the same proof as the two above, and it reaches this arm from BOTH re-drive modes. Its remedy is to restore the recorded path carrying the status the re-drive routes on, never to commit: the arm's own condition means there is no corrected spec at that path to commit, and the path can be a shared artifact directory outside the project that is not a repository at all. The refused arm raises rather than resuming, so there is no gesture left to hold, and the remaining arm carries no imperative of its own — rearm-spec-write-unreachable holds that leg. The whole re-arm is one transaction, and what it covers is stated narrowly: the SPEC's BYTES, from the first spec write to save_state. That save is the commit point — until it returns the run still calls the story escalated, so any fault escaping the window in between (a journal write that fails, a non-git fault from the stale-restore commits probe, an interrupt during one of the three git probes, or the state write itself) used to leave a spec flipped to the re-drive's status and stripped of its ## Auto Run Result against a task nothing had moved. Every one of them now restores the spec to the bytes the re-arm found and re-raises the original fault unchanged, so the escalation stays armed. One in-window tree change is deliberately outside that scope: clearing a sentinel unlinks the file rather than writing spec bytes, and it is not re-created, because _clear_sentinel already preserves a copy under {run_dir}/sentinels/ and a retried resolve re-clears it idempotently. The re-arm says which of those it did. rearm-aborted is journalled from the rollback and echoed by both surfaces, carrying restored (a write had landed and was put back), unchanged (the file was read and PROVED byte-identical — a refusal sequenced ahead of every write), failed (the restore itself could not write, so the spec may be part-written — that one raises rather than degrading, keeps the original fault in the exception chain, and names the file to restore — from git or your own copy, since the bytes it failed to write are gone with the process and a spec is not necessarily tracked — in both the message and its next step), or unknown. The last covers everything the undo could not confirm: the cleared sentinel, a re-arm that resolved no spec path at all (the record then carries an empty locator and the notice says (none)), and a spec that is gone or unreadable by the time the undo looks, which it declines to re-create rather than fight whatever removed it. The surfaces then report that nothing was persisted and the story is still escalated WITHOUT claiming the file on disk is intact — an unconfirmed outcome must never render as the reassuring one. Without this record the surfaces described the residue of a re-arm that had been rolled back — files "excluded from the re-drive baseline" for a baseline never saved. Two boundaries keep the undo honest. It refuses to write at all when it could not first CAPTURE the spec's bytes from a file that is there — a transient read fault followed by writes that succeed would otherwise leave a published flip with nothing to put back — and it does NOT undo a re-arm whose save_state demonstrably committed, since that state write is a single atomic rename whose call can still be interrupted on its way out, and rolling the spec back underneath it would build the same defect mirrored. All of these warnings reach the TUI's re-arm as well as resolve's — both route every kind through one shared table, so neither surface can silently learn a kind the other drops, though each still owns where it calls the echo from and the TUI drops the trailing "before resuming" advice, since it otherwise resumes in the same gesture. A successful re-arm returns those rendered notices and its hold verdict as one immutable authoritative outcome, captured only after each journal append succeeds and in append order. The CLI and TUI consume that outcome directly, so an unreadable journal cannot erase a successfully appended hold from the combined re-arm/resume gesture; best-effort journal diffing remains only for diagnostics already appended by a call that aborts before it can return an outcome. Each re-arm also bumps a per-task generation, so the re-minted session id cannot collide with the abandoned attempt's record — ids already on disk keep their exact spelling, since the suffix appears only above generation zero (#705). Sweep migration and triage tasks make the same rollover automatically when an ESCALATED task restarts with a fresh attempt budget; mid-flight, non-escalated restarts keep their current generation because their continuing attempt counter already provides a fresh id.

  • Attempt-owned sprint-spec recovery (#123, #630): a bound plain attempt whose only residue is its own lifecycle flip is normalized back to its pre-attempt lifecycle status, proven Git-clean, and retried. Every bound retry chain snapshots its first spec input byte-for-byte and retains it across both dev-verification and review-verification repair sessions; a resolved re-drive therefore retains the operator-corrected ready-for-dev input rather than a failed child's later body. Repair entry points validate retained authority before constructing a prompt that can reset the spec. A non-fixable retry parks the failed child first, restores that snapshot, and re-establishes the promised route after resetting sibling residue. Restoration is anchored at a handle on the spec's parent directory on both supported hosts — the POSIX dir_fd family, and on Windows NT handle-relative opens (win32_at: NtCreateFile with a RootDirectory, FileRenameInformationEx/FileDispositionInformationEx with POSIX semantics, reparse points refused as a link) — retains the staged inode across publication and verifies that exact inode before accepting it. A host with neither arm refuses before staging or lifecycle normalization, leaves the current attempt-owned spec bytes untouched by restoration, and pauses for manual adoption. The same snapshot restores pre-launch operator edits when a plain child puts a tracked spec back at Git baseline. Git-ignored and pre-existing-untracked bound specs use the byte snapshot as their dirtiness oracle and are force-included only in the private recovery ref before restoration; index-only force-adds and cached removals also trigger cleanup and restore baseline index ownership. That real repair reports rollback-owned-spec-restored, never rollback-skipped-clean. Missing, unreadable, deleted, retargeted, changed external, or unsafe legacy authority pauses once with spec-specific adoption instructions and clears the unusable pair so manual recovery can converge; recovery also refuses a reset whose baseline would replace the canonical path or a parent directory with a symlink, tree, file, or other unsafe shape. An initial Sprint binding fault may safely degrade to an unbound bare-key launch; an existing Stories folder+id target instead aborts unless it can be snapshotted. Once an explicit binding is durable, a later snapshot fault aborts before child launch while retaining that authority for recovery. Fresh sprint tasks with no recorded path remain bare-key dispatches; other substantive changes or sibling residue follow rollback policy; Stories remains folder+id; Sweep remains intent-bundle routing; snapshots are retired after commit; and recovery never auto-commits the human correction.

  • Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an intent gap, the dev primitive saves the attempted change as a patch file (referenced from the halt output) before reverting the tree. If that reading turns out to be correct, the resolve agent adds "restore_patch": "<path>" to its resolution.json; the orchestrator re-arms the spec to in-review (not ready-for-dev) and re-applies the patch after every reset, so the re-driven session resumes review on the restored diff instead of re-implementing. bmad-loop resolve --no-interactive --restore-patch <path> does the same by hand. A patch that fails to apply escalates rather than dispatching onto a half-restored tree. Sweep bundles get the same recovery.

Git worktree isolation (opt-in)

  • Off by default ([scm] isolation = "none" — work in place on the checked-out branch, byte-for-byte the prior behavior). Set isolation = "worktree" and each story (and each sweep bundle) runs in its own git worktree on a bmad-loop/<run_id>[/<story>] branch cut from the target branch, then merges back locally — the main checkout stays free while a run is in flight.

  • Explicit bundle artifacts (DW-283, DW-289, DW-290, DW-300, DW-301, DW-302, DW-303): successful isolated integration publishes the accepted ignored bundle spec before removing the worktree. To publish additional ignored regular files, name exact paths relative to configured implementation_artifacts in the accepted spec's optional artifact_deliverables frontmatter list (for example, artifact_deliverables: [errata/correction.md]). That directory must lie strictly inside the code repository. Directories, globs, absolute paths, traversal, symlinks, and orchestrator-owned ledger/board files are refused. Tracked files continue to ride Git; undeclared ignored files are not copied. At every newly accepted dev, repair, or review verification boundary, the run durably binds the complete selected ignored path set to SHA-256 digests and the complete tracked or pending-tracked path set to Git-clean-filter-normalized blob identities, all under an append-only session-record identity. The final source commit stages once, validates each declared Git path against that accepted blob identity, then commits the already-validated index without restaging; a committed-tree proof detects hook or concurrent-index mutation and restores the original commit chain before refusal. Target integration carries that same immutable authority through merge, squash, fast-forward, and crash replay: ordinary target hooks still run, but the final target commit and post-hook index must contain every accepted tracked blob and keep every accepted ignored path absent, every incoming path must sit in the post-hook index and checkout exactly as the integrated commit has it — its index flag word included: a fresh entry carries none (skip-worktree too on a sparse target, where git sets it outside the cone and strips a hook's from an in-pattern entry), or the word the receipt captured for the path, so a hook's update-index --assume-unchanged or --skip-worktree on an incoming path, which changes no blob and hides later edits from git, is refused by path — (an incoming path that commit deletes must be absent from the checkout, not recreated unstaged by a hook — a path the commit holds through a prefix, a file or submodule it turned into a directory, is held, not deleted, and a leaf beneath a symlink the commit holds, a directory it turned into a link, is absent by topology rather than read through the link; a populated submodule checkout git leaves behind when the commit deletes its gitlink is accepted only as the exact captured checkout — this repository's (its git dir under the target's own git dir's modules/, which also holds for a target that is itself a linked worktree), clean, at the captured HEAD — and stays on disk as ?? path/ for the operator, exactly as git left it — and when the commit replaces a populated submodule with a tracked directory, git writes the commit's files into the checkout it could not remove, and that leftover (its .git still inside the directory) is accepted only owned, at the captured HEAD, and reporting nothing through its own status but paths the integrated tree holds under it; every incoming path the integrated commit holds as a gitlink, captured or newly added, must carry exactly that gitlink in the post-hook index (a cone-mode sparse checkout's skip-worktree flag on an out-of-cone gitlink is that entry's ordinary shape and is accepted — on a sparse target only; elsewhere it is a hook's — and an operator's update-index --assume-unchanged bit on a captured gitlink is recorded in the receipt and preserved — a gitlink the commit rewrote may carry the captured word or a fresh one, since git merge writes the entry anew and clears the bit where a fast-forward or squash keeps it, an untouched gitlink must carry exactly the captured word, and any other word is a hook's; the restore puts the captured word back where git restore cleared it), and a populated checkout there must be this repository's, clean, and at the gitlink (for a gitlink the commit introduces, "clean" counts ignored entries too — the receipt proved the path absent, so everything there is attempt-era; a captured checkout's ignored entries are read against the listing the receipt sealed of them beside its HEAD, below) — read directly, because an incoming .gitmodules with submodule.<name>.ignore = all hides a hook's submodule update --init, its writes into the new checkout, a moved HEAD, and even a rewritten gitlink from every git diff reading), and the squash leg's own commit must have sealed the tree merge --squash resolved (so a target pre-commit hook cannot rewrite an incoming source file into the bundle's commit), and after the hooks the target may hold no dirt outside every one of those sets but the strays the guard tolerated before the merge and an ignored file that was already there which an incoming .gitignore change uncovered (untracked now, and held by the receipt's ignored listing at the identity it still has; one held under another identity was written during the attempt and is named) — a whole-tree status reading names any other entry, and the receipt's digest of every other index entry's flag word — with the entries carrying a word no fresh entry may, so a flip can be named — refuses a hook's update-index --assume-unchanged or --skip-worktree on a clean tracked file outside the incoming set, which status never lists — and the receipt's lstat identity of the file behind every entry outside that set the index already trusted unread when it was armed (assume-unchanged or skip-worktree; None for one not on disk, a sparse target's out-of-cone entries among them) refuses a hook's overwrite, truncation or removal of such a file, which moves no word, no blob and no status or diff reading, the way an ignored file's overwrite is refused (the incoming set names both sides of a rename, rename detection off, so the source a merge deletes is never an entry outside it) (a clean tracked file a hook edited, staged, deleted or renamed, a new file it wrote — under the orchestrator's own .bmad-loop/ as anywhere else: the hook relay script, a committed policy.toml, a profile overlay, with only the run's own records — runs/, archive/, cache/, decisions.json, operator/, operator-actions.json — left out, and the pre-merge guard reading that directory on the same terms, an unstaged stray there tolerated and a staged one blocking; a writer racing the merge window is named the same way), and the receipt's sealed listing of the whole tree's ignored entries, taken when it is armed with each entry's lstat identity — size, mtime, ctime, inode, device, mode; bytes are never read (a NUL-delimited sidecar under the operation's capture root, the run's own records left out) — names every ignored entry the tree holds after the hooks that it did not record, or recorded under another identity, and every entry it recorded that is gone — a hook's gitignored write beside an incoming path in a directory the target already held populated, which status never lists and the introduced-directory walk has no root at, inside a directory that was already wholly ignored, over an ignored file that was already there, overwritten, truncated or touched in place, or its deletion of one, which status and diff are as silent about — with a tolerated stray an incoming .gitignore change turned ignored left out, and, from the removal reading, every incoming path and every entry beneath one (git's own write clobbers an ignored d/x when the commit puts the file d); a recorded entry that left the listing but still stands is not a removal (uncovered by the incoming .gitignore, it is the stray reading's); the refused receipt's residue reading skips the recorded side, so deleting a named rewrite — whose identity nothing can restore — clears it, and a named removal is the operator's to weigh before resuming (ceiling: a writer that puts size, times and inode back is not read) — a listing that also walks the tree for every nested .git entry, directory, gitfile or symlink, which git lists in no reading at all (.git is administrative, and a fresh directory holding nothing but one is passed over too), so a hook's git init beneath a populated tracked directory, or a repository it puts inside an ignored one, is refused by path the same way; each repository boundary is one entry, nothing inside it read, and the .git of a checkout the submodule reading accepts at a gitlink the commit introduced, or the receipt captured unpopulated, is tolerated rather than named twice — and the same listing sealed for each captured populated submodule checkout beside its HEAD, which the tree's listing never descends to, the checkout reading never asks for, and the superproject's own status never reports (it names a submodule's modified and untracked content, never its ignored content), names a hook's write the checkout's own .gitignore covers after the hooks, into a submodule the commit leaves alone, one whose gitlink it rewrites (the hook having moved the checkout to the new gitlink, which the checkout reading allows), or either leftover — with the commit's own paths written into a replaced leftover left out, and a receipt armed before the listing was recorded reading as it did, and a directory the commit created — where the receipt proved nothing was, or proved an empty untracked directory (the first existing ancestor of an incoming path, which git never tracks and no reading lists; on a refusal it is left absent or empty again, residue there pausing the run as not safely restorable), or where it captured a tracked file, a symlink, or an unpopulated gitlink the commit now holds only as a prefix — is walked on disk against that commit — every file a path it holds, every directory a prefix it holds or a gitlink left to the submodule reading — so a hook's gitignored write or nested .git there, which no status reading lists, is refused by path (on that refusal the restore keeps the proved-absent directory's doctrine below: it removes nothing it cannot attribute, and the run pauses as not safely restorable, the path named) — before unit-merged, publication, carries, or teardown can be recorded (a target-side hook that rewrites paths during the run's later pathspec'd bookkeeping commits is outside this check). On that refusal the restore reverts what the hook staged, which is in the post-hook index delta it already restores, and leaves unstaged, untracked and ignored entries where they are — the run cannot tell a hook's write from an operator's in that window — naming them in the pause. The accepted artifact paths are resolved on the target before the receipt is armed; a resolver refusal pauses there, ahead of collision cleanup and any Git mutation, rather than snapshotting short. Before collision cleanup or any Git mutation, each modern attempt atomically persists a versioned transaction receipt covering its target/source/strategy identity, target ref epoch, exact index stages, worktree kind/mode/bytes, absent parent topology (and an incoming path's first existing ancestor proved an empty directory), a digest of the flag words of every index entry outside the snapshot set (and the lstat identity of the file behind each of those the index trusts unread), cleaned collisions, tolerated dirt, declared ignored presence or expected absence (a leaf beneath a tracked file or symlink the unit turns into a directory is absent by topology — a dangling symlink reaches nothing, and one that resolves, into the repository or out of it, reaches another path's entry, which git never tracks through a link — so no receipt reading dereferences the link: the capture, the completeness and post-hook readings, and the checked-path restore's own ancestry preflight all confine the leaf by the link's parent, the link itself captured and put back as bytes under its own path; a leaf beneath a symlink the incoming set does not name is refused before any mutation; and the restore goes through the parent path alone, since git refuses the pair — the put-back symlink is the receipt's own shape, not a redirection, and a proved-absent parent beneath the restored file is absent by topology), and clean populated submodule checkouts (an unpopulated gitlink — a clone without --recurse-submodules — is an empty directory, recorded unpopulated: a checkout a target hook's submodule update --init makes there is attempt-era whole and a refusal's restore removes it, leaving git's empty directory; a repository of any other kind there — a fresh git init, which git also names a submodule while the gitlink is indexed — refuses the restore; a tracked directory the commit put in the gitlink's place is git's to empty on the restore, which then puts the empty directory back, or refuses over anything left in it; and after the hooks such a checkout is read as one the commit introduced, ignored entries counted). Collision cleanup advances through durable pending/applied phases, so replay restores a partial cleanup before replanning; an in-process cleanup fault restores only the paths the cleanup finished or had in flight, never a planned path it had not reached (fresh operator state there is left alone). Exact recovery bytes stream into no-follow confined run-owned sidecars with size and SHA-256 metadata; their aggregate uses limits.artifact_payload_max_mb (10 MiB by default), so neither memory nor state.json grows with destination bytes and an over-limit capture refuses before target mutation. Wide restoration pathsets travel through NUL-delimited stdin rather than process argv, and a receipt path is held to the Win32 name rules (reserved characters, device aliases, a drive prefix, a backslash) on a Windows host alone — on POSIX git permits :, ?, *, \ and control characters in a name and every reading round-trips them literally; containment (absolute, .., .git, NUL) holds everywhere — and its absent-parent topology is read by git's slash hierarchy, the one the capture wrote (a name such as a: or one holding a backslash is a plain segment to that reading, a drive or several segments to a Windows one). The operation identity is coupled to the target reflog update, so host-loss replay reads the exact old/new transition under a live receipt without trusting commit messages, parent shapes, or the append journal — but no record retires a receipt or stands for a modern bundle's completion: a coding session holds the writable run directory and can append a unit-merged row under its own story, branch and target, the receipt's operation identity included, and it holds the shared repository, so the reflog transition under that identity is its to write too (git update-ref -m bmad-loop-integrate:<id> writes exactly that row, on a branch checked out in another worktree as well). A live receipt always replays the merge, which finds the moved ref under the receipt and validates the landed result, or pauses with evidence; with no live receipt — a host lost before one was armed, or after a successful integration retired it — the merge replays while the unit's source is still mounted (it stages nothing again over a landed result, re-validates the target's bytes and re-records; an integration that made no ref update, an artifact-only bundle's squash that staged nothing, the same way — a target that already holds the source proves nothing about bytes another writer changed since), and once the completed integration has consumed the source (worktree torn down, branch gone) the completion stands only on the target as it is now — the unit's commit in the target's history, or, since a squash seals a commit of its own and never that one, every change the unit made over its baseline folded into the target's tree blob for blob (mode and object id per added or rewritten path, each deleted path absent) — and every accepted artifact blob in its tree and index — or the run pauses naming the reason (the released legacy payload keeps its bare row). A mismatch restores only while the exact receipt-owned result still owns the ref and the reflog old side matches the snapshot epoch; it restores and verifies the complete index/worktree, trackedness, expected absences, sidecar bytes, and confined indexed submodule HEADs before recording refused-restored; a captured submodule checkout is restored to its captured HEAD with whatever the attempt wrote into it undone (tracked content reset, untracked files removed; ignored files are named from the receipt's listing and never touched), including a checkout whose gitlink the refused integration had deleted; a submodule checkout of this repository standing at a path the receipt proved absent (the integration introduced the gitlink, a hook populated it) is removed whole, whatever it holds, while a directory of any other kind there may be fresh operator state and refuses the restore. Tolerated operator dirt and pre-cleaned untracked bytes survive. A moved ref, stale epoch, redirected submodule, missing/corrupt snapshot, malformed receipt, or incomplete restore never authorizes a reset or a success claim: concurrent state is preserved, path-only refusal evidence is journaled, and the source remains for manual recovery. Typed Git refusals and successful no-ref-update attempts retire authority only after proving all receipt-owned state stayed unchanged; a refused no-ref-update attempt records its unchanged revision on both sides so its receipt replays. Restoration completeness is read over the receipt-attributable inventory (commit delta, post-hook index delta, accepted artifact paths): a tracked file the operator edits, unstaged, after the receipt is armed is theirs and never turns a completed restore into an incomplete one. A well-formed frozen dictionary payload from an earlier version keeps its released integration/publication path without acquiring a reflog prerequisite or synthesized rollback authority; malformed or partial modern shapes fail closed. A pre-staging Git-content change, missing or malformed index entry, unmerged entry, non-regular entry, or ignored-to-tracked classification change pauses through the ordinary publication-refusal route with path-only evidence and retains the source mount and original binding; restoring Git-equivalent accepted content lets resume continue without another session. If a deterministic commit hook or other writer repeats the mutation on every attempt, correct or disable that writer before resuming. A writer landing after staging cannot enter the retained commit because validation and commit consume that same index and the committed tree is checked before integration; a clean no-op (nothing staged after the validated snapshot, as when a concurrent writer resets the index to baseline) is checked against baseline the same way rather than recorded as the bundle's commit. Payload preparation rereads the same exact ignored selection and re-derives the tracked path set (every pending-tracked path is tracked by then, the final commit having staged it), and freezes base64 bytes only when the whole ignored mapping and the tracked path set both match — so a spec that lives outside implementation_artifacts, which is never itself a selected deliverable, cannot swap one tracked declaration for another after acceptance; the accepted tracked paths' blob identities were already proven on the index and committed tree, so the working tree behind the sealed commit is neither reopened nor re-hashed as authority (a tracked deliverable rewritten or removed there after the commit is tolerated). A post-verification byte, selected ignored path-set, or tracked declaration change pauses through the ordinary publication-refusal route, leaves the destination untouched, and retains the source mount and accepted binding for recovery; restoring the accepted bytes lets resume continue without another session. Replaying the same accepted result never refreshes authority from current source bytes, while a distinct accepted repair or review replaces the binding. Ignored inputs are deduplicated and measured as raw bytes: defaults admit at most 5 MiB per file and 10 MiB total (limits.artifact_file_max_mb, limits.artifact_payload_max_mb), inclusive, where 1 MiB is exactly 1,048,576 bytes. Metadata preflight precedes every payload encoding, and fixed-chunk bounded reads recheck both caps so a growing file stops at one sentinel byte over budget without turning an arbitrarily large configured limit into one enormous read request. A binding or preparation size refusal leaves artifact_payload unset and retains the source even with keep_failed = false; because the accepted identity is already armed, changed bytes or policy cannot be adopted by replaying that same result—a new accepted dev, repair, or review result is required. A frozen payload from an earlier version remains publishable and idempotent without synthesizing a binding; a legacy COMMITTING replay skips re-finalization only when its persisted commit already equals HEAD. Destination baselines are captured before execution, independently of both accepted-source binding and the artifact-only receipt. Their SHA-256 capture and every publication comparison stream fixed-size chunks, so operator-owned destination size does not become a destination-sized allocation; growth or shrinkage observed during a read is refused. A completed destination read also compares the opened descriptor's device, inode, and regular-file state with a fresh confined, no-follow pathname observation; a leaf detached by the time of that sampled post-read observation is refused. The observation is not a lock or compare-and-swap: a later rename can still race it, and checked fallback platforms retain their check/read race. Publication writes only when the destination still matches that baseline or already holds the intended bytes. A conflict or publication fault pauses integration and retains recoverable source artifacts even with keep_failed = false. Durable publication state lets resume finish an interrupted publication idempotently before successful cleanup; an older task without baseline authority cannot acquire overwrite permission by resuming. An accepted artifact-only attempt needs no tracked delta for publication and teardown. Publication preserves file bytes, but creates files privately at mode 0600; it does not preserve file modes or xattrs. The destination check runs after staging, immediately before replacement. It is not a lock or atomic compare-and-swap guarantee: noncooperating filesystem writers can race the final check and replacement.

    Capacity regression basis: on CPython 3.13.15/Linux, five saves of a 10 MiB raw payload produced about 13.34 MiB of base64 state in 0.278 s with 34.4 MiB peak traced memory; preparation plus one save peaked near 45.3 MiB. The regression test pins the exact 4/3 base64 structure and uses deliberately cross-platform ceilings of 160 MiB peak and 20 seconds for five saves; these ceilings detect unbounded copying without serving as performance promises.

  • Merge knobs: merge_strategy (ff / merge / squash), target_branch (default = branch checked out at run start; created if missing — a detached HEAD or unborn repo pauses the run instead of merging onto an unreferenced commit), branch_per (story or a shared run branch; run forces delete_branch = false), and delete_branch.

  • Dirt in your MAIN checkout only blocks a unit merge when the merge — or the run's own post-merge bookkeeping — could commit it (#460, #618). Two questions, asked per path over the strays that lie outside the branch's incoming set. What the MERGE can commit is what git has staged, so the axis is the index column rather than trackedness: an untracked stray and a tracked stray edited in the working tree only are both inert — measured on git 2.55.0 across both topologies and both strategies, rc 0, the edit survives uncommitted, and it is absent from the resulting commit — so both are left exactly where they are and the guard journals merge-target-tolerated naming them. Before this, one unrelated notes.txt (untracked, #460) or one saved-but-unstaged edit (#618) in the main checkout escalated the first story and paused an unattended run. A staged stray still escalates, because merge --no-ff refuses it outright and a fast-forwardable merge --squash folds it into the story's commit — against a diverged target --squash refuses too, with the same error as --no-ff, so the fold is a fast-path artifact rather than a squash property. What the RUN can commit is the second question: the post-merge carries stage the sprint board and the deferred-work ledger by pathspec (git add -- :(literal)<path>), which takes whatever the working tree holds no matter who wrote it, so any dirt on one of those two paths escalates whatever its index column says — otherwise an operator's private reopen of a story row rides out under a chore(sprint-status): carry … message with the tree left clean and nothing to read the substitution back from. That protection covers artifacts git already tracks: an untracked one has no baseline to diverge from, the orchestrator has been reading that exact file as its own all along, and committing it whole is how a non-ignored board first reaches git (#350) — protecting it would halt the first story of every project that has yet to commit its board. Either escalation names the paths and asks you to commit, stash or revert them (it never cleans them for you), and each names its own remedy, since staged work has to be committed or unstaged while dirt on a carried path has to leave the path entirely. merge-target-tolerated records what the guard decided and is emitted before the merge, so a stray it waved through by path can still clash with the incoming commit by shape — a file where the merge needs a directory, or the reverse — and git then refuses at pre-flight over the very path the event called harmless; that run journals a corrective merge-preflight-refused beside it, naming the same paths and carrying git's raw text (#623). A per_worktree engine Editor leaking this branch's own files into the main checkout is still auto-cleaned and journaled merge-target-cleaned, since those are duplicates of content the branch already committed.

  • A merge-back that fails escalates in one of five typed shapes rather than one, plus an honest fallback for anything unclassified (#619). Git declining at pre-flight — an untracked file the merge would overwrite, a staged change on an incoming path, a file/directory shape clash, a merge_strategy = "ff" target that cannot fast-forward — is not a conflict: nothing was merged, the target checkout is exactly as it was, and there are no markers to find. That escalation now says so and lets git's appended text name the cause and the paths, instead of sending you to resolve a conflict that does not exist; a genuine content conflict keeps the resolve-by-hand wording, and both keep the unit's branch and worktree mounted for manual recovery. The third shape is a --no-ff that merged cleanly and was then refused at the commit — a pre-merge-commit or commit-msg hook exiting non-zero, or a commit.gpgsign that cannot sign. Nothing conflicted, so it leaves no unmerged stages, but it does leave MERGE_HEAD: an index-only reading calls that started merge a pre-flight refusal and sends you to clear a clash that does not exist. bmad-loop aborts it, restores the checkout, and points you at the policy that declined instead. Telling the three apart takes both probes — git ls-files -u leads and answers content, and MERGE_HEAD, read before the abort that erases it, parts a merge that never started from one that started and could not be sealed. Neither alone is enough: a conflicted merge --squash writes three unmerged stages and conflict markers while creating no MERGE_HEAD at all. The squash leg reaches the third shape through a different door: --squash itself stops before committing by design, so no hook or signature can refuse the merge invocation — but the leg then seals the staged result with its own plain git commit, where commit hooks and commit.gpgsign run like anywhere else, and a refusal there is the same third shape. That one is rolled back with git reset --hard HEAD, gated on the pre-merge reading having found the tree clean — a checkout already carrying uncommitted work of yours is never reset, and the reset is deliberately whole-tree, since it undoes a merge that SUCCEEDED, whose staged result spans the entire incoming set (the stated ceiling: an edit landing after that clean reading rides the reset); when the rollback is withheld the squash result is left staged, and the escalation says so and names clearing it as your first step. Neither the exit code nor the message text can stand in, the same refusal being rc 1 or rc 2 depending on topology, rc 1 also being what a conflict returns, and the message being fully translated. The fourth shape is the one all three strategies reach and no index reading can see: git dying part-way through the CHECKOUT. It materializes the incoming files in index order, so a failure partway — measured under a required clean/smudge filter that cannot run, on all three strategies — stops with HEAD where it was and the tree already partly rewritten. --ff-only is not exempt: it declines the topology question before touching anything, but once the fast-forward is possible it checks the incoming tree out like any other merge, so the "it never starts a merge" premise that used to excuse this leg from checking was simply wrong. That leaves no unmerged stages, no MERGE_HEAD, and a tree that reads clean against HEAD (an untracked file is in neither HEAD nor the index), so every probe above calls it a pre-flight refusal and tells you the checkout is untouched — while the residue refuses the NEXT merge as an untracked-overwrite, identically on every resume, over paths nothing named. Residue probes answer it: the untracked set and the dirty-tracked set are each sampled before the merge, differenced after, and the deltas intersected with the branch's incoming set — the only paths a merge can write — so the answer is "git wrote this", not "this is here" and not "something changed while the merge ran". The intersection closes the seventh mislabeled state: with attribution read off a repo-wide "the tree is dirty now" boolean, an edit YOU made to an unrelated tracked file while the merge was failing was attributed to git — a genuine pre-flight refusal classified as "failed part-way through checkout", and the repo-wide reset --hard HEAD riding on that attribution destroyed your edit (measured on all three strategies). The residue has two axes and they get different answers. An incoming path the target did not already track lands untracked, and nothing reaches it — neither git merge --abort (which exits 128 here anyway, there being no merge to abort) nor git reset --hard touches untracked files — so it is named for you to clear rather than cleaned, since the delta proves git wrote a path, not that the bytes now there are yours or git's. An incoming path the target did track is rewritten in place, which a path-scoped git checkout HEAD -- <path> over exactly the attributed paths does undo, so that half is undone for you on every leg — never by a repo-wide reset, whose blast radius would take your own uncommitted work on paths the attribution deliberately left alone — and the escalation asks only for whichever residue actually survived, rather than reciting both; a failed restore names the exact paths and prescribes the same path-scoped command. Two ceilings worth knowing, both per path rather than per tree: a path that was already dirty before the merge stays unattributable — its delta cannot say which bytes are whose — and an edit landing during the merge window on a path inside the incoming set is indistinguishable from git's own write, so it is restored with it; "cannot tell" and "git did nothing" fail to the same side on purpose, the alternative being a restore over your uncommitted work. The differencing is load-bearing and not tidiness: an absolute reading would reclassify every genuine pre-flight refusal that happens to have an untracked stray in the checkout, which is exactly the stray the guard above deliberately tolerates. Relatedly, a squash merge git refuses no longer discards your uncommitted work: --squash has no --abort, so the recovery is that same path-scoped restore over whatever the deltas attribute to git — your pre-existing dirt is never touched, and a conflict landing on top of it has the conflicted incoming paths restored while your edit stays put. The fifth shape is one of the classifier's own post-merge readings failing after a failed merge — the residue deltas or the incoming set that attributes them, the unmerged-stages reading, or the MERGE_HEAD reading: what rested on that reading is then unverified, and the escalation says exactly that — every verdict still standing on its own live measurement keeps its class, cleanup not gated on the dead reading still runs, and an unread MERGE_HEAD skips the abort it gates and says so rather than repairing on uncertainty — and you are sent to the one reading the run could not take, your own git status, instead of a probe failure impersonating a verdict. A reading can die on the far side of success too: a merge --squash that succeeded still needs one index reading to tell a no-op replay from a result to commit, and with that reading dead the same unverified escalation fires — nothing committed, nothing reset, any staged result left in place — rather than the doomed git commit that used to dress the failure as a commit refusal and roll the tree back on the fiction. The conflict itself is typed from its own measurement too (the unmerged stages), so the final catch-all no longer equates "anything unrecognized" with "content conflict": a failure none of the probes classified escalates saying just that — the run cannot say what state the checkout is in; git status and git's own appended text are the sources — rather than prescribing conflict resolution for a state nothing measured.

  • Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (keep_failed, default on) and its full diff is preserved to run_dir/failed/<unit>/changes.patch; failed_diff_max_mb caps per-file untracked-file size (oversized skipped with a marker), failed_diff_unlimited lifts the cap.

  • Config seeding: a worktree checks out tracked files only, so a project's gitignored MCP/CLI configs (.mcp.json, .claude/settings.json, .codex/config.toml, .gemini/settings.json) would be missing — an isolated session couldn't reach its MCP server. With seed_adapter_defaults (default on) each loaded adapter's own seed_files are copied in from the main repo before the session launches, together with every non-hookless adapter's resolved hook config_path — which is gitignored for codex, so without it the project's own hook configuration never reached the worktree and the session ran against a file holding bmad-loop's relay registrations alone (#471). Seeding does not decide whether that relay hook registers — the hook-merge writes it either way, with one refusal: a seeded config that is present but will not parse stops provisioning outright (GitError → CRITICAL escalation, run paused) instead of being read as an empty document. JSON has no partial read, so an unparseable config is evidence of an earlier fault rather than a blank slate — and merging the relay into {} would publish a hooks-only file over the operator's allowlist, env and MCP entries, erasing that evidence along with them. The bytes are left exactly as found, and the refusal names whichever source actually supplies them rather than the copy it read: an escalated story re-enters the run only through a re-arm, which discards the worktree and provisions a fresh one, so a repair applied to the disposable copy is undone before the next drive. Which source that is comes from the seed bookkeeping rather than from a counterpart's existence — seeding is copy-when-absent, so a config the project TRACKS is skipped as an occupied destination and arrives with the branch checkout, and that lane is told to commit the repair on the target branch while a genuinely seeded one is sent to the main checkout. That bookkeeping is recorded per path actually written rather than per seed entry: a directory entry seeds child by child, so its presence proves that some child landed and never that this config did, and inferring from it would advise committing a gitignored settings file the checkout supplied. The remedy names the re-arm (bmad-loop resolve <run-id> --no-interactive) because ESCALATED is terminal: repairing the file alone leaves the story out of the run (#592). Invalid UTF-8 takes the same lane, which before this crashed the engine instead of escalating. worktree_seed adds extra paths. Every configured seed path is refused at config load if any component names a Windows device or ends in a period or space (#480) — worktree_seed, an adapter's seed_files/seed_globs, its hook config_path and skill_tree, a plugin's [python] module, and the Unity seeder's guard dir all take the same refusal, on every platform. Such a component names a different path on Windows than the one it spells: NUL and aux.json are devices rather than files, and .claude/skills. is created as .claude/skills because Win32 strips a trailing run of periods and spaces before the path reaches the filesystem — so the copy lands somewhere the operator did not configure, and the exclude pattern the shield later renders from the authored spelling names a path that does not exist. Refusing at load is what keeps the render site below from ever seeing one. It is a compatibility break on config that previously loaded, and a hard refusal rather than a warning for the reason the value is dangerous at all — it means one thing here and another there, and a run that quietly seeds a different file than the one configured is the failure the guard exists to prevent. Copy-when-absent at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — seeded before the hook-merge (a seeded settings.json keeps its content, but its relay entry is replaced rather than kept — an older seeded copy can name a workspace relay missing in the worktree; left in place it would read as already registered and the session would stall silently, #352; a tracked config that needs the same rewrite is pinned skip-worktree in the worktree's own index, so the local command never rides git add -A into a story commit — while pinned the config is orchestrator-owned, so a story's own edit to it stays session-local), and shielded from the unit's git add -A — in a private exclude scoped to that worktree alone (see below), never repo-wide.

  • Seeding is verified result-side, by re-probing the worktree on disk rather than trusting the copier's bookkeeping — so a user-authored worktree_seed rel can neither forge nor mask a report: worktree-seed-skipped (a seed entry that copied nothing), worktree-seed-dropped (a repo-carried seed that never arrived), and worktree-module-skills-dropped (#464 — a wheel-bundled bmad-loop-* skill whose content came up short, e.g. a checkout file squatting the skill's directory, which the per-file no-clobber refuses to replace) are all informational, journaled and never a pause. Presence is the whole contract; content is never compared, since no-clobber is per file and a checkout's own divergent fork of a bundled skill is a healthy shape. Only the upstream dev/review skill and renderer completeness probes escalate, because those have a deterministic in-worktree consumer — the session dispatches them inside the worktree and stalls having written nothing — while the bundled operator/triage skills are dispatched at the main checkout (/bmad-loop-sweep from sweep triage, bmad-loop resolve; bmad-loop-setup has no session consumer at all), so their absence in a worktree stalls nothing.

  • A fresh mount that supersedes an accepted-but-uncommitted spec says so (accepted-spec-write-unreachable, DW-101). The pause_after_spec gate hands you a spec that is uncommitted by construction, and a re-drive's worktree is a checkout of a commit — so for a tracked artifacts dir git worktree add delivers the pre-approval bytes, seeding skips an occupied destination, and the delivery probe above passes on existence alone while the session reads text your corrections were meant to replace. The mount's copy is byte-compared against the main checkout's with line endings normalized (CRLF ≡ LF — the one translation a checkout under core.autocrlf applies on its own, and one git folds away again on commit; any other byte still counts), and a difference — or a read that fails either side, recorded as compared: false, since a probe that cannot read cannot prove delivery — is journaled naming the main-checkout spec path (the file to commit) and the target branch to commit it on. Informational, journaled and never a pause, exactly like the seeding records above: it does not overwrite the mount's copy — a dirty tracked file there is not covered by the worktree-scoped exclude, so the unit's git add -A would fold your in-progress edits into the story commit — and it does not refuse the mount, which would hard-fail every isolated unit in a project that tracks its artifacts dir. The escalation path's counterpart is rearm-spec-write-unreachable, whose remedy is the same sentence.

  • A mount that cannot be shown to carry the accepted spec at all says so too (accepted-spec-delivery-unreachable, DW-104/DW-115). Two silences end here, both on a spec the task already spells project-relative — the spelling a resume persists, and the one leg with no escalating guard over it. Either the spec's mounted parent escapes the worktree (a real directory in your checkout, a committed outward symlink in the commit the worktree was cut from), so seeding refuses it on containment; or the spec could not be located at all — a filesystem fault while resolving it was swallowed, or the path no longer resolves because the file is gone. Both used to produce the same nothing: the unit dispatched against a mount lacking your spec, fell back to the bare story key, and no journal named why. Now the refused rel is also nominated as a seed — nothing is copied outside the mount, because the copier re-checks containment itself, but worktree-seed-dropped names it — and the record is journaled naming the main-checkout spec path and the target branch. located says which silence it was, and the two have different remedies: true means the spec was found in your checkout but the mount could not be shown to carry it, so commit it on the named branch (or unpick the outward symlink that makes the artifacts dir unseedable) and re-drive. A file probe fault also produces true, even if the bytes are present: check filesystem access and I/O health when the file is already delivered. false means it could not be resolved in your checkout at all, so the named path is the one to restore or re-point before re-driving. Informational, journaled and never a pause, exactly like the seeding records above. The relocated leg — an absolute spec normalized for the mount — gets no record here, because it already escalates on the same loss a few lines earlier; it may still carry a worktree-seed-dropped entry for the refused rel, which is the seeding report rather than a second advisory.

  • The git-add shield is scoped to the worktree and expires with it (#384). Provisioned tool files (skill trees, hook config, seeded configs) go into a private .git/worktrees/<id>/info/exclude, activated by a worktree-scoped core.excludesFile that shadows rather than concatenates with your own — so yours is copied into the private one byte for byte, the shield covers that unit only, git worktree remove takes it away, and the shared, permanent .git/info/exclude is never written. An explicitly empty core.excludesFile is honored literally — no excludes file at all — rather than read as unset, so git's XDG fallback is not consulted and there is nothing to copy. Where that fallback IS consulted, the file it names is asked of git rather than computed in Python — and on Git for Windows >= 2.46 the file is %APPDATA%/Git/ignore whenever that file exists, which the fork prefers over the $HOME/.config/git/ignore upstream resolves (#403). Seeding the $HOME one there was wrong in both directions: with only the APPDATA file present the seed came back empty and the activation then shadowed the ignore file git really reads, and with both present the worktree over-ignored with patterns git was not applying, so session-created files went missing instead. The gate is the reported version's own .windows. fork string plus that floor rather than the platform — Cygwin, MSYS2 and WSL gits run on Windows hardware without the patch — and anything else, an unanswerable git version included, keeps the $HOME answer. Read from the fork's source through #403, not measured on a Windows machine. It then proves it applies or stands down: bmad-loop asks git which excludes file it actually resolves and from which scope (git config --show-scope), so the degrade reason names the winner rather than inferring one — ambient command-scope config (git -c, GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT) is told apart from a worktree-scoped write that is not in force at all, which sends you to a different repair (#692). Anything but a confirmed byte-identical match — either of those, an unreadable excludes file, an answer naming no scope, an unanswerable probe — skips the shield with a journaled and notified reason; a match activates it whatever scope supplied the value, since the thing being proved is that git reads the file bmad-loop wrote. Two runs against one repository serialize on an exclusive lock, leaving a zero-length .git/bmad-loop-shield.lock — never in the working tree, so nothing your git add -A can see; on Windows the wait gives up after ~10s and the shield is skipped naming the lock. Caveats: its gate is the project's git 2.34 support floor (an older git — or one that cannot say what it is — skips the shield, and the repo-format flag is deliberately not written); that is a support refusal rather than a capability one, since the two things the shield is built out of, extensions.worktreeConfig and git config --worktree, have existed since git 2.20 — runs are refused outright below the floor, so what this gate still catches on a current host is a git that cannot be spawned, times out, or answers unparseably. Enabling it sets extensions.worktreeConfig — a permanent repo-format flag, rolled back wherever it could be left set without a working shield, but surviving in two cases the reason distinguishes: a sibling worktree still depends on it, or the rollback could not be made at all. Where it cannot be set safely at all (core.bare = true or core.worktree in the shared config) the shield is skipped instead. It is skipped the same way where the shared config already carries an explicit extensions.worktreeConfig that is not true (#396): that line is an operator's own declaration, and enabling over it would rewrite it to true permanently with nothing recording what stood there — so the shield stands down and the reason quotes the spelling it found, raw rather than --type=bool-normalized, since false, off, no and 0 all read back as false. A valueless worktreeConfig line is not that case: git reads it as true, so the flag counts as already carried and the shield proceeds. Lines an older bmad-loop wrote into .git/info/exclude are not removed for you — delete them by hand. A file your project tracks gets no pattern: git applies ignore rules only to untracked paths, so the pattern would shield nothing while making the file read as tracked-and-ignored to git ls-files -ci --exclude-standard and to repo-hygiene gates built on it (#392). A tracked directory gets no directory pattern either — it is replaced by one pattern per file this provisioning run actually wrote below it (#484). A dir pattern does hide new children, and no pattern shape both hides them and keeps the report clean (dir/* with per-child negations clears the report and leaks a new file into the commit), but over a tracked tree it bought new-child coverage alone at the price of a false tracked-and-ignored report for every tracked child — modifications to those stage regardless. The accepted trade: a file the session creates under such a directory can be staged, which matches your own decision to track that tree, while everything the orchestrator seeded there keeps a pattern of its own. An untracked tool directory is unchanged — it keeps the single /dir pattern. If git cannot say what a path is — or a file provisioning wrote below a tracked directory has a name no exclude line can spell (an embedded newline, a trailing carriage return), which would shield nothing and loose an orphan pattern on unrelated files — the pattern is kept in its ORIGINAL shape, dir shape included, and the reason is journaled. Patterns are escaped as they are rendered (#476): a rel holding a trailing space or a wildmatch special (*, ?, [, \) otherwise names something else, leaving the seeded path unshielded while the broken character class silently hides an unrelated file the unit meant to commit. The spellings that made a trailing period or space diverge no longer reach this render site at all: a configured component Win32 would trim, or that names a device, is refused at config load (#480), so the shield never has to reconcile an authored spelling against a directory Win32 created under a different name. That is the only direction in which the trailing-space case closes — escaping it to /skills\ fixes the gitignore parse and still names a path mkdir never created, because the trim happens below git rather than inside it, so the dot and the space are one defect with one shape rather than two.

  • Run state never moves into a worktree — .bmad-loop/ always lives in the main repo; spec paths are persisted relative to the worktree so a kept-failed run stays portable.

  • The sprint board is worktree-canonical for the duration of a story (#350). A board your project tracks needs nothing special: the story's advance is an ordinary modification of a checked-out file and rides the unit commit through the merge. A gitignored board is neither checked out nor delivered by one, so it is seeded into the worktree alongside the deferred-work ledger — the orchestrator writes the board through the worktree and verify_dev then reads the file it just wrote, which without the seed is a missing file the run dies on rather than a lost write. Its advance is re-applied to the main checkout's board after the merge, journaled board-advance-carried — or board-advance-carry-uncommitted where git add refused the ignored path, which is the ordinary outcome for such a board and not a fault (the status on disk is the value; the commit is best-effort) — or board-advance-carry-refused where the board is no longer a publishable file at all by the time the commit runs, naming which of target-absent / target-unreadable / target-not-a-file fired (a board is validated as a "store", which asks nothing about bytes, so the fourth cause below cannot arise for it). That last outcome is the board's share of a rule that now holds everywhere: every exact-commit publisher proves its target is publishable before it hands that operand to git (DW-237). git add reads its operand as a literal pathspec and stages a directory's descendants recursively, so a directory left at a board, ledger, spec or park-record name would otherwise publish an unrelated tree under a chore(...) message. The other publishers journal the same refusal under their own names — harvest-carry-refused (the harvested-deferral carry), story-deferred-close-carry-refused (#458's declared closes), sweep-bundle-close-carry-refused (a sweep bundle's closes) — and bmad-loop confirm, which has no journal, drops the offending operand instead, per operand, with one warning: line on stderr for each (its gitignored-board probe still runs ahead of the guard; that git call asks about the board, it does not publish anything). A refusal never raises. The causes split DURABLE against TRANSIENT, and the guard draws the line, not the caller: target-absent, target-not-a-file and target-undecodable (a ledger holding bytes nobody can decode — the ledger family's own cause, split out of target-unreadable so the two are never confused) are shapes a replay re-reads and refuses identically, so there is nothing a retry could repair, and every publisher refuses them. target-unreadable is an OS fault a probe RAISED — an unsearchable parent, a UNC provider that is registered but not serving — which the next pass may not see, so the one publisher holding a durable commit latch — the harvested-deferral carry — does not refuse that cause but keeps it on its ordinary git-failure path, where the GitError is raised (latch kept, the next pass retries) unless the ledger is one git cannot own, in which case it degrades to harvest-carry-uncommitted exactly as any other GitError there does; the other publishers, holding no latch, refuse it too. Before the split, an undecodable ledger arrived at that carry as target-unreadable, fell through, and was committed — git accepts any bytes. The carry replays from its record if a crash lands between the merge and its latch, and sprintstatus.advance never regresses, so a double application is a no-op. On that replay leg the merge — and with it the pre-flight above — has already happened, so the carry proves its own ownership before committing: it recomputes HEAD's content through advance and asks git whether the board holds that and nothing else, which a crashed pass's half-written advance does and an operator's edit does not. Sameness is git's own — both sides are hashed through the path's clean filter — so a board spelled CRLF by one host and LF by another still matches, where a byte compare would have to guess which spelling is on disk and would refuse a pristine board on every host that chose the other. Both places git holds the path are proved, because the carry's git add overwrites both: the working tree it copies into the commit, and the index it stages over — an edit staged and then restored in the working tree exists nowhere afterwards, so proving the working tree alone would authorize destroying it. That proof guards the commit, which is one write too late for the story's own row: advance would already have replaced an operator's status with the target, leaving exactly the bytes the proof accepts — and skipping the commit saves nothing when the value scheduling reads is the one on disk. So that one row is checked before the advance as well, and a status that is neither HEAD's nor this pass's own is refused there with nothing written. Both refusals journal board-advance-carry-foreign-dirt, and they refuse at different points with different stakes: the pre-advance row check refuses with nothing written, so your status is still on the board; the post-advance proof withholds only the commit — the advance is on disk by then — and the dirt it declined to take is still escalated by the next run's merge pre-flight. A board that is gone by carry time is refused before either question is asked and journaled board-advance-carry-failed, the same record a vanished row gets. Every probe fails closed, and git's own dirt answer decides whether either comparison is asked at all — a board nobody wrote is not their question (#618). Both comparisons apply to a board git tracks. A gitignored one has no baseline anywhere in git to be compared against, so a replayed carry can still overwrite a row you edited on it while the host was down; the commit half cannot arise there at all, since git add refuses an ignored path outright. Scheduling is what depends on it: _pick_next reads the main board, so an advance that never came back hands finished work to the next run's dev session.

  • Only the orchestrator writes the board — the session prompts forbid it, and sprintstatus.advance is the sole write path. The consequence under isolation, stated rather than left as a surprise: in a gitignored board a session's edit to any OTHER story's row lives only in the worktree copy and evaporates when the worktree is removed. Only the story's own advance is carried back, because only that one is the orchestrator's own write.

  • bmad-loop confirm leaves a gitignored board out of its commit (#577). git add refuses an explicitly named ignored path and refuses the whole operand list with it, which would take the spec's flip to done and the park record's deletion down too — leaving the tree dirty for the next run's preflight to refuse. The board is still advanced on disk; a board git will not track has no commit to ride. Each of the three operands is proved publishable on its own before the commit (DW-237): one that is present but not a regular file (a directory left at the spec, board or park-record name) or that a metadata probe cannot read is dropped from the list alone, with a one-line warning: on stderr naming the path and the cause, and confirm still exits 0 with the surviving operands committed — the on-disk flip is the value. An absent operand is never dropped: the park record is unlinked before the commit by design, and staging that deletion is exactly what #356 needs it in the list for.

  • Merge-back is serialized; max_parallel is a validated knob clamped to 1 until parallel fan-out is built. The repo_root key in _bmad/bmm/config.yaml (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos), and your [verify] commands run there too, as does every git question the dev gate asks about the code — baseline identity, ancestry, and the non-empty-diff proof, with their exclude pathspecs spelled relative to the same root (#695, #716) — while the orchestrator's own artifact reads stay project-rooted — but it is mutually exclusive with isolation = "worktree", which seeds from repo_root while the preflight probes project; validate reports the pair and the run/sweep/resume preflight refuses it (#414; #443 lifts the restriction).

  • commit_message_template ({story_key} / {run_id} / {story_title} substituted) customizes story/bundle commit messages.

Plugins (extensibility)

  • A first-class plugin system extends the orchestrator without touching the core loop. A plugin is a folder-drop plugin.toml manifest (under .bmad-loop/plugins/<name>/, overlaying bundled bmad_loop/data/plugins/<name>/) that can: observe / veto / mutate the run at every lifecycle stage via a hook bus; contribute settings that render in the settings TUI and persist to [plugins.<name>]; and inject its own workflow sessions at post_dev_phase / post_review_result.
  • Two trust tiers: a data-only / declarative plugin (settings + [hooks.<stage>] shell commands) runs on discovery; a plugin that ships an in-process [python] module is never imported unless listed in [plugins] enabled — dropping a folder in never runs code. Every hook (subprocess or Python) is failure-isolated: a raise is caught, journalled, and disables that instance for the run — never crashes it.
  • Veto maps onto the engine's existing control flow (skip/defer/pause), and mutation is confined to a per-stage whitelist (proposed_prompt, proposed_commit_message, …) plus a persisted shared dict — no new abort path. Distribution is folder-drop now, with a documented bmad_loop.plugins entry-point seam for pip-installed plugins later.
  • See Writing a bmad-loop plugin for the manifest, settings, hook, stage, trust, and workflow reference, plus a worked walkthrough; a complete example ships under examples/plugins/guardrails/.

Game-engine projects (opt-in)

  • A niche engine layer — built on the plugin system — for projects whose dev/sweep cycle drives a live engine Editor via an Editor MCP (Unity bundled as bmad_loop/data/plugins/unity/; Godot/Unreal later). Off by default; enable with [plugins] enabled = ["unity"] + a [plugins.unity] table. (The legacy [engine] policy block still loads, folded onto [plugins.unity] with a deprecation warning; project-local overrides now live under .bmad-loop/plugins/<name>/.)
  • editor_mode is coupled to [scm] isolation because a live Editor MCP can only act on the folder its Editor has open: shared (requires isolation = "none") runs the agent in place on the project the operator's warm Editor already has open — zero relaunches, full live MCP; per_worktree (requires isolation = "worktree") gives each worktree its own managed Editor.
  • Readiness gate: before each unit, the plugin's pre_ready_gate hook blocks until the Editor + MCP report ready (Unity: wait-for-ready for IvanMurzak, connectivity check for CoplayDev); on timeout the unit is deferred with an ATTENTION notice rather than starting a session against a half-open Editor.
  • per_worktree lifecycle (Unity/IvanMurzak): a setup hook launches the worktree's own Editor (MCP port auto-derived from the worktree path, so it self-isolates from the operator's main Editor), writes the worktree .mcp.json, and primes the worktree's Library with a reflink/CoW copy of the warm main Library (so Unity reimports incrementally instead of a cold full reimport that crashes the import workers; deep-copy then symlinked-empty-cache fallbacks off-CoW); the readiness gate then waits for it; a teardown hook quits the Editor on completion and on pause/escalation. The MCP-generated skill tree (gitignored) is copied into each worktree via the plugin's seed_globs; a setup failure defers the unit instead of running it against no Editor.
  • The Unity plugin's settings are editable in the TUI under its plugin section. To target another engine or a different Editor MCP, see Writing a Game Engine plugin (now built on the general plugin system) and Writing a plugin for a specific Editor MCP.

Resumability & state

  • Every run is a resumable on-disk state machine: bmad-loop resume <run-id> continues from a gate, escalation, or interruption.
  • A graceful stop (stop --graceful / TUI S) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a stopped run that resume picks up at the next item.
  • Every state.json publication is serialized by one advisory lock per run, keyed on the resolved run directory plus the logical state.json name and stored under the user state root rather than in git. Ignoring a final-component state.json symlink keeps that identity stable when atomic publication replaces the directory entry; alternate spellings of the run directory still converge. Multi-step control mutations (resolve, resume, code-root restamping, and stop's external fallback) hold that same lock from their authoritative read through atomic publication, so a waiter reloads the state its predecessor left instead of overwriting it from a stale snapshot. A fresh run or sweep likewise holds it from its initial state save through trusted-digest and PID publication, preventing an explicit-id resume from observing resumable state before the composer is live. Readers remain lock-free because publication is atomic. Stop does not hold the lock while it requests, signals, polls, or kills: a live engine must be able to publish its own stopped state; only the fallback's final reload/check/write is serialized. That final check preserves an engine that finished during delivery and retries against any newer live engine generation a concurrent resume published. POSIX lock acquisition blocks, while Windows can surface an OSError after its bounded wait; either failure aborts the mutation rather than writing unlocked. Two callers acquire without waiting instead: clean, sweeping many runs, and the TUI's re-arm, which runs on the dashboard's message loop where a blocking POSIX acquisition would freeze the whole UI for as long as the holder kept the lock. Both report the contended run rather than queueing behind it.
  • All run state in .bmad-loop/runs/<run-id>/ (gitignored): state.json (which records repo_root, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. resolve and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); journal.jsonl (every decision, including the session-synthesized-from-frontmatter catch and its spec-marker-repaired repair, #276, plus one verify-command-result per verifier command — emitted on the dev, repair and review legs alike, carrying verification_stage and a per-story verification_sequence that orders the passes across all three; the two passes that leave no record are bmad-loop confirm --reverify, which runs after the run is over, and any pass with no [verify] commands configured, which records nothing because nothing ran — each entry also carrying spawn_error, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time OSError (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — whose stream pointers name the verify/ directory below, and one park-proof-of-work-skipped per attempt that cleared the dev artifact gate on an awaiting-operator park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying zero_diff: true when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), false when it found changes, and null when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. false is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it), and one bundle-artifact-only-accepted per sweep-bundle attempt that cleared the dev artifact gate on the artifact-only receipt (DW-273) — carrying story_key, attempt, dw_ids and count, the number of ignored files under implementation_artifacts this attempt created or changed against its start-of-attempt snapshot), and one bundle-artifact-baseline-unavailable per attempt whose snapshot could not be taken (a git fault; the receipt then refuses for that attempt); tasks/<id>/ (per-session prompt + shared artifacts: [result.json, escalation.json] — respectively the per-session result and escalation outputs — plus adapter-specific breadcrumbs: session-lifecycle.jsonl records timeout fires, budget-guard trips (budget-tripped / over-budget-fired), transport-failure classification (env-fault-classified, #194), a mux session lost under the run (session-vanished, #489) and the #276 forensics (spec-status-transition-observed, frontmatter-unmodified-refused, contract-nudge-sent); heartbeat.json is the wait loop's proof-of-life, carrying transcript_idle_s — seconds since the live transcript last changed, null until a hook event names it (#680); resultless-stops.jsonl records give-up Stops with a verdict — no-artifact, ambiguous-frontmatter, unmodified-since-launch, terminal-frontmatter-pending); logs/; verify/ (verifier command stdout/stderr, one file per stream, pointed at by the journal's verify-command-result records — its own store, because every name in logs/ is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to [verify] stream_capture_kb (256 KiB, 0 = keep nothing) and the record carries the full byte count, a truncation flag, and a capture_error when the write itself failed); deferred/; resolve/; ATTENTION; ctl-window (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482).
  • One piece deliberately lives outside that directory: the hook-event channel (#494) is at <state root>/<project>/<run-id>/events/ under the user-scoped state root (BMAD_LOOP_STATE_DIR, see the transport section below and the README's env-var table), not <run-dir>/events/. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. delete, archive and clean remove the out-of-tree counterpart along with the run dir, and clean sweeps counterparts whose run dir is already gone; an archived run's tarball therefore no longer contains events/ — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir.
  • journal.jsonl records session-end for every session unconditionally — even a teardown that throws still lands one (status aborted when the outcome is unknowable). A timed-out session's entry carries fired_at (wall time the deadline was declared), teardown_s (wall seconds from that fire to this entry — the teardown gap), and expired_clock (monotonic / wall / both — wall alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries tokens (raw) and tokens_weighted (cache reads at limits.cache_read_weight), keeping per-session spend reconstructible; both are null when the usage read failed, and both are absent on an aborted end. tokens_weighted is the end-of-session total — distinct from a tripped session's budget_weighted, the guard's mid-session sample at trip time.

Hook-based transport (no pane-scraping)

  • Coding-agent hooks (Stop / SessionStart / SessionEnd / PreCompact) write structured event files the orchestrator watches; skills write a machine-readable result.json.
  • The event channel lives outside the project tree (#494), at <state root>/<project>/<run-id>/events/ — a branch switch, a worktree mount or a rollback must not be able to take a live run's control plane away. Each session is told where to write via BMAD_LOOP_EVENTS_DIR; the state root itself resolves per BMAD_LOOP_STATE_DIR (see the env-var table in the README). The relay falls back to the legacy in-tree <run-dir>/events when that variable is absent, and the orchestrator keeps polling that location too — older sessions may still carry a copied relay, and without both halves those sessions would stall to session_timeout_min. Fresh init registrations call the installed bmad-loop relay <Event> executable by absolute path and require no workspace relay copy.
  • What the move is and is not worth: it eliminates the whole in-tree redirect class — nothing an agent writes inside the project can any longer point the completion channel somewhere else — and it is a hard boundary for a sandboxed session, which cannot reach outside the tree at all. It is not a boundary against a session running with permissions bypassed: that session is told the directory by BMAD_LOOP_EVENTS_DIR, so it can reach it by construction. This is why the relay keeps its own _is_link_like refusal on the write path (#493) as belt-and-braces rather than retiring it as redundant.

Deferred-work sweeps

  • Skills accumulate an append-only ledger (deferred-work.md, DW-<n> entries): split-off goals, pre-existing findings, "needs human decision" items.
  • Legacy-ledger migration recovery (DW-296/DW-297) persists a current-format marker plus run-owned migrate-baseline.md and migrate-rewrite.md text snapshots. The baseline and its exact reconstructed manifest are durable before dispatch; migration rechecks the live cycle input before and after publishing those records and once more after executable pre-session hooks at the actual adapter-launch boundary (DW-316). Drift or a read fault first persists PENDING with no baseline authority and refunds an attempt that launched no adapter, then retires the stale records, so even a cleanup fault cannot resurrect stale dispatch authority. The rewrite becomes authoritative only after deterministic validation. A result-publication fault therefore leaves sweep-migrate nonterminal, and cycle-one resume compare-and-set restores the accepted legacy text before redispatching without overwriting concurrent ledger bytes or resetting past an advanced HEAD. Once migrate-result.json is durable the task enters committing: resume validates the baseline/manifest/rewrite/result set and retries only the exact-path publication tail. For migration, that tail captures the checkout's immediate and terminal branch identity, refuses rival committed ledger content (including a ledger the baseline commit tracked that a later commit deleted — absence is accepted only when the baseline commit proves it was never tracked), stages an isolated hook-observed candidate, and validates its exact parent, one-path scope, Git-normalized accepted blob, and regular resolved target. The candidate carries the live ledger's own bytes, read once they decode to the accepted text under the ledger readers' universal-newline reading — so the committed blob is what git add of the validated file would stage under any line-ending configuration, and a CRLF checkout is not left dirty beside an LF commit — and every later validation re-reads those bytes and holds the target's inode, size, mtime and ctime, refusing a rewrite in place (rival bytes, or the same text under other line endings) before the transaction commits. It then prepares an expected-old transaction against only the captured terminal branch, proves that ref is still direct while its lock is held, and commits the transaction without updating symbolic HEAD (DW-311). A lost commit acknowledgement is settled only by re-observing and fully validating the candidate or by a later retry from unchanged authority; it is never compensated. Post-publication target-index synchronization brackets target-local resets with bounded checkout observations, preserves unrelated stages, repairs toward the newest observed committed tree, and refuses a moving attempt for replay. Resume can recognize the validated ledger transition beneath unrelated first-parent descendants without moving them. DONE is earned only by that accepted commit or by a clean outcome that still contains the accepted rewrite. Missing, nonregular, unreadable, malformed, or mutually inconsistent current-format records required by the task's persisted recovery phase escalate without reset or publication; a missing result during triage-verify instead triggers the intentional restore-and-redispatch path, while unmarked pre-upgrade tasks retain the older reset-and-reread route.
  • Ledger read contract (DW-146/DW-279): read_for_write returns None for the existing metadata-absence cases and nonregular targets. Nonabsence metadata faults and all text-read OSError failures, including disappearance/type-change races after a successful probe, raise LedgerReadFault(LedgerReadError) with the original exception chained as __cause__. Invalid UTF-8 still raises LedgerReadError with a UnicodeDecodeError cause. A refused authoritative read publishes nothing. Pre-lock presence probes, lock acquisition and writes retain raw OSError; observation reads retain their empty-text-plus-attributed-fault degradation. Consumers distinguish OS refusal from decoding before handling the parent exception.
  • Frontmatter harvest bridge (BMAD-METHOD#2640/#2651; shipped 0.9.1, hardened #433): since BMAD-METHOD 6.10.1-next.33 the unattended primitive records defer-triaged review findings in its spec's frontmatter deferred: list (summary/evidence, optional location/severity) and writes nothing to the ledger. The orchestrator harvests them itself — post-session but above the artifact gate, so before verification and before the attempt is accepted — into canonical ### DW-<n> entries, so deferred-work.md stays the sweep's sole read surface. Entries therefore appear even when the attempt goes on to fail verification: a fixable retry deliberately keeps them (the attribution reference moves onto the kept tree), and _harvest_gate_exclude stops the engine's own append from counting as the session's proof of work. Dedupe key is the fingerprinted origin: spec-deferred <fp> plus source_spec:, scanned across entries of every status, so a replay neither doubles an entry nor re-opens a closed one. Era-agnostic (the gate is the field's presence, never the skill name resolved on disk) and bounded to sessions bmad-loop drove to a success status — in-review with the follow-up review enabled, else done, plus an operator park; a plan-halt checkpoint keeps its notes for the implementation pass. A spec outside the orchestrator-owned roots is refused (spec-deferrals-skipped-out-of-tree) and an unreadable one retries the session rather than accepting it with findings silently dropped; deferred: items that will not parse are journaled (spec-deferrals-malformed) and filed as one low-severity entry naming the spec. A ledger whose bytes do not decode is routed at every one of the engine's own four read_for_write sites (DW-231) according to what the read was about to do: the observation reads — the proof-of-work digest, the pre-harvest snapshot, the defer snapshot and the two restores' compare-and-set probes — degrade to a typed answer nothing can write back or anchor a write on (the digest hashes the raw bytes, so "did the ledger change" stays exact; the snapshots stay unarmed; a restore skips and journals) and journal ledger-read-degraded naming the site; the two reads that precede a publish — this harvest's append and the isolated unit's carry into the main ledger — normally journal ledger-read-refused, raise an ACTION REQUIRED notice naming the ledger, and pause the run at escalation; a sweep's terminal post-merge harvest carry instead journals sweep-bundle-close-refused and pauses at story-gate, while its direct pre-terminal defer carry retains the engine route. Both routes leave the task's phase untouched, so bmad-loop resume after the hand repair retries the write — resume recovery replays the recorded session result where one exists (the dev and review legs) and otherwise re-drives the leg (the unlatched review-timeout salvage and fix legs) — rather than, as _escalate would, demanding a bmad-loop resolve session and a clean rebuild over a fault that is not the story's. Bare, the first of those reads ended a story run as run-crash with the completed session's work on disk. The route also covers the window INSIDE each write, for decode faults (DW-259) and OS metadata/text-read faults (DW-279): every deferredwork mutator takes its own locked read_for_write — after the routed pre-read at the harvest and the harvest carry, after an observation snapshot at the commit-boundary close, and with no pre-read at all at the review-timeout salvage refile (deferredwork.append_entry) and the isolated close carry — so a LedgerReadError raised from the mutator call itself — the harvest's seen-again mark and append, the commit-boundary closes_deferred: close, the salvage refile, the isolated unit's harvest carry and close carry — pauses through its owning repair route under a site name ending in -locked (spec-deferrals-harvest-mark-locked, spec-deferrals-harvest-append-locked, story-close-locked, review-timeout-salvage-refile-locked, harvest-carry-append-locked, story-close-carry-locked); the locked read fires ahead of every write, so a pause there proves the mutator wrote nothing, which is what lets the commit-boundary close disarm its rollback first rather than journal a deferred-close-rollback-failed against bytes it cannot read. The notice names both kinds of write (findings to file, a declared close to record); bmad-loop resume re-drives the close through the COMMITTING arm, and a pending salvage refile through its persisted retry latch (DW-278): resume verifies the preserved product again, refiles the outstanding follow-up, and commits without new dev/review sessions or additional attempt/cycle charges under either rollback policy; ordinary commit gates still run, including any configured pre_commit_gate workflow sessions. An unrepaired ledger pauses again with the latch retained; a failed salvage verification follows the usual retry/exhaust routing. Successful refile records the publication while retaining recovery authority through notification and commit gates — the latch is set at every salvage's handoff save, the first fault-free one included, not only after a repair pause, so a host lost between that save and the commit replays the salvage rather than restarting it; the durable COMMITTING transition clears the latch. Legacy and unlatched timeouts keep the baseline restart or manual recovery behavior governed by scm.rollback_on_failure. The replay is the story engine's: a sweep bundle's recovery (_recover_inflight_bundle) has no session-replay arm at all, so a latched bundle restarts as every other post-session bundle does and its restart clears the latch, so the abandoned product's salvage cannot force a review on the replacement attempt. LedgerReadFault(LedgerReadError) wraps OS metadata/text-read failures with the original OSError as __cause__, so the locked-read catches cover them without catching lock/write failures. Pre-lock presence probes, lock acquisition and writes keep their raw OSError behavior. A read the OS refuses (EACCES, EIO, a symlink cycle) is routed the same way at the same four sites (DW-258): the observation reads degrade to a typed answer that carries NO digest — nothing is read from a ledger the OS refused — so the proof-of-work digest becomes an <unreadable> sentinel and the attribution compare treats a sentinel on either side as UNKNOWN, which is never credited: the ledger path stays excluded from proof of work, so the engine's own harvest append after the hand repair cannot pass a session that wrote nothing (a session whose only work was a ledger edit over a refused baseline is rolled back and retried over a readable one); the publish reads pause with the same ACTION REQUIRED notice, whose repair sentence now names both repairs (valid UTF-8; the path's permissions or storage). Where no publish read is reached — a spec with no findings — the story completes over the refused ledger, and a declared closes_deferred: then journals deferred-close-ledger-unavailable and sends a best-effort notice through the configured notification channels (including ATTENTION when file notifications are enabled), naming the story, every unapplied declared ID, and the fault (DW-277). The snapshot outage writes nothing to the ledger and does not pause or crash the story. Pre-rename primitives and the attended bmad-build still append flat - source_spec: blocks directly, which sweep --migrate normalizes.
  • Story-declared closure (closes_deferred: [DW-5, DW-6], human-authored on a stories.yaml entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to status: done <date> + resolution: resolved by story <id> — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and bmad-loop validate warns about all of them before the run starts. (A non-list closes_deferred in stories.yaml is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (deferred-close-external-ledger). If the advisory ledger snapshot cannot be read, the declared closes remain unapplied and open entries stay open; the outage is journaled and notified as above. Restore ledger readability (valid UTF-8 and accessible permissions or storage), then run bmad-loop sweep: the next sweep re-triages those IDs against the actual code and can close verified resolutions as already_resolved, with the completed story's commit serving as evidence.
  • Hard gates (gate: 3-2, 3-3 on an entry): until the entry lands, bmad-loop validate fails (deferred.hard-gate) for every actionable story a token matches and run pauses (story-gate) rather than dispatch one — a token gates a key it equals or prefixes at a key boundary (-, or a split-story suffix), so 3-2 covers 3-2-invite-link, the stories-mode id 3-2 and both halves of a 3-2a/3-2b split, but never 3-20-later. Only an explicit status: done retires a gate; an unreadable status (opne, or no status line) still gates. The dispatch pause precedes the story's own run record, so closing the entry and resuming runs it; sweeps are exempt, since a sweep is what closes the gating entry. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (deferred.hard-gate-unstructured) covers the four gates nothing can enforce: a token that cannot name a story key (3-2 3-3, or an unmatchable 3.2 — ./_ are legal inside a sprint slug), an empty gate: line, a gate: not lowercase at the start of a line, and prose declaring HARD GATE: (matched mid-line, since reason: prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no gate: line. Migration is checked rather than merely instructed: a sweep --migrate rewrite that drops a gate: token a pre-existing entry declared is refused, restoring the original ledger (#519); an added token is accepted, since over-blocking fails loudly and in the safe direction. A ledger already carrying duplicate DW-<n> ids is refused before any rewrite is dispatched, pausing with the ids to renumber: no rewrite of it can both preserve the entries and produce a valid ledger, so the only one that would pass collapses the pair and drops a twin's gate.
  • bmad-loop sweep triages every selected open entry against the actual code (all open entries by default; ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. --only DW-1,DW-3 requires every named id to exist and be open on the initial post-migration ledger; repeats and resumes intersect that fixed scope with entries still open. --min-severity low|medium|high|critical is re-evaluated each cycle and includes the named level and higher; missing or unknown severity is excluded and explicitly reported. Selection precedes triage, bundle formation, and --max-bundles; dry-run uses the same rules; both exclusion classes are journaled; selectors persist in sweep.json, are mutually exclusive, and cannot combine with archive mode. On a mixed legacy ledger, named dry-run selection projects provisional ids by continuing from the highest canonical id in manifest/file order, labels selected and excluded projections, and rejects projected done/unknown ids without spawning migration; semantic duplicate merging may compact the final mapping, so the real run revalidates against the actual post-migration ids. Before the first-attempt validation, an otherwise-valid lowercase ASCII/kebab bundle name or decision-option bundle_name over 40 characters is truncated to 40 and journaled; malformed shapes and post-truncation collisions still fail through the existing retry path. Text bmad-loop status on a sweep run prints a sweep options: line giving max_bundles, repeat and max_cycles at their effective values — the run's sweep.json override, else its policy_snapshot (never live policy.toml) — labelled override or policy, plus any selector; options that fail the resume loader's bound or digest checks read unverifiable with the reason, and --json is unchanged (#815).
  • Bundles run the full pipeline (dev --dw-bundle → review → verify → commit); the review gate checks every bundle entry is status: done.
  • Artifact-only bundles (DW-273): a bundle whose only permitted deliverable lives under a gitignored implementation_artifacts dir (a spec-only erratum) can never satisfy the ordinary dev proof-of-work probe, which measures tracked and untracked-not-ignored paths only — and a bundle has no awaiting-operator park to fall back on. The bundle dev session may instead assert it by appending an Artifact only: true line inside its ## Auto Run Result marker, beside Status:. Accepted spellings: Artifact only, artifact_only, Artifact-only — a run of at least one space, underscore or hyphen between the two words (the fused Artifactonly is no spelling and asserts nothing), case-insensitive, in the same bulleted or bolded label/value shapes as Status: (**Artifact only:** **true** and - **Artifact only: true** both assert) — with the value true alone on the line (anchored to end of line: Artifact only: true for the ledger, false for code is prose, not an assertion) and, like Status:, on the SAME line as its label (Artifact only: with true on the next line is a bare label, not an assertion; any horizontal whitespace, NBSP included, is fine); a line inside a fenced block within the marker is documentation and mints nothing. The synthesized result carries artifact_only: true only when that line sits in the current session's last genuine, non-fenced marker with no orchestrator repair note — the same four-part shape and the same session-authorship proof (park_marker_session_authored) that mint park_asserted; frontmatter never mints it, and a loose "true"/1 is no assertion. Gate rule: on the bundle path only (verify_dev_bundle — a story result asserting it still owes the ordinary diff), after the ordinary probe has positively answered "nothing changed", a strict artifact_only: true makes the gate run git status --ignored --porcelain --untracked-files=all scoped to the configured implementation_artifacts dir and count only its IGNORED (!!) records — the tracked and untracked-not-ignored ones are what the ordinary probe already measured, so under the bmad-loop init default layout (where _bmad-output/ is not gitignored) a bundle's own spec flip or newly written spec is no receipt — and keep only the entries THIS ATTEMPT created or changed: ignored paths carry no git baseline, so the sweep engine fingerprints (lstat mtime and size) every ignored entry under the dir at each genuinely new attempt's start (StoryTask.baseline_artifacts, stamped beside baseline_commit and persisted in state.json; a fixable repair keeps the chain's snapshot, a rolled-back retry takes a fresh one since rollback never removes ignored files), and an entry absent from that snapshot or carrying a different fingerprint now is the attempt's, while one unmeasurable at either end is not. A positive owned count is accepted as proof of work; residue that all predates the attempt (lists N ignored entries, none created or changed by this attempt), an empty listing, a task with no snapshot (a pre-upgrade run, or a capture that degraded to bundle-artifact-baseline-unavailable on a git fault — the attempt is still driven, only the relaxation is withheld), an artifacts dir outside the code tree (or equal to it), or a git refusal keep the ordinary retry with the refusal's cause appended to the verbatim no changes in worktree since baseline commit reason, and a git fault at the gate escalates like the ordinary probe's. An accepted receipt is journaled as bundle-artifact-only-accepted (story_key, attempt, dw_ids, count — the number of owned entries). Under scm.isolation = "worktree" an in-tree artifacts dir is rebased into the unit worktree, so the receipt measures the worktree's copy and the success teardown removes that worktree — an artifact-only unit lands no tracked change, and the ledger carry re-applies the close but copies no file. What survives is what publication carries (DW-283, below): the accepted ignored spec and the exact files its artifact_deliverables list names are published to the main checkout ahead of the teardown; undeclared ignored residue is not copied, and the bundle prompt says so (DW-284). An artifacts dir OUTSIDE the code tree is not rebased and survives the teardown, but the receipt is refused for it regardless of isolation, since git lists nothing outside the repo (the "outside the code tree" refusal above). The limit: ownership is read off fingerprints, not content, so a rewrite that lands byte-identical with a preserved mtime is invisible to it, as it is to the ordinary probe; the assertion selects the receipt, the snapshot is what makes it proof. Nothing else relaxes — workflow tag, expected status, baseline match, dw_ids cross-check, your [verify] commands and the review gate's every-id-done check all still apply.
  • Artifact-only bundle guidance (DW-284): initial, restored-review, and repair bundle prompts advertise this conditional receipt: assert it only for the current session's actual qualifying deliverables, never for ordinary changes, other nonqualifying deliverables, or old artifacts alone. Sweep triage describes qualifying deliverables and their location in the existing bundle intent field; the executing session owns the assertion, with no added triage receipt field. Successful isolated integration publishes explicit ignored deliverables through the separate publication contract in Git worktree isolation; accepting the receipt alone does not publish files.
  • Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as decision: lines. Unattended runs leave decisions open.
  • A deferred-work ledger that will not read no longer ends the sweep as crashed (DW-166). The already-resolved close and an attended decision's own ledger effect both degrade to a journal row and carry on — sweep-resolved-close-unavailable (naming the ids it was about to close, which stay open for the next cycle to re-triage — since DW-193 that row has two further producers, both the same probe deciding whether an already-landed close still needs publishing, where the ids may already be done and nothing was about to close at all — one inside the close phase, one at the sweep loop's nothing-open exit, where the phase never runs; in every case the row means no usable ledger was read and nothing was published) and sweep-decision-effect-unavailable (naming the decision and its effect). The decision arm is the reachable one and the one with a hazard behind it: the prompt blocks on the human, so a ledger corrupted or locked while it is open faults only after the answer is already saved to <run>/decisions.json and journaled decision-answered. That ordering is deliberate — the human's answer must survive a crash — so the degrade is the fix rather than a reorder, and the faulted decision emits no post_decision and is not counted closed. What the human is left with then differs by effect. A close leaves the entry open, and since DW-167 the re-apply walk below picks it up without a new run: a later cycle (or a resume) finds the stored close over a still-open entry and applies it, so a ledger that reads again repairs itself. A build still materializes its bundle from the saved answer, since a read fault does not prove the entry is absent. Dispatch requires the phase's final ledger doubt to clear, for example when a later effect succeeds; the faulted build may then run without its own decision: audit line. The same row also fires with no raise involved (DW-186): record_decision returns a boolean, and it reports writing no line when there is no ledger file at all or no entry carrying the id — a rival writer can retire one while the prompt is open. That non-write shares the raise's dispositions (no post_decision, not counted closed, the row naming which state it was) but not the build trade: a False return is positive proof there is no entry to build against, so since DW-200 such a build answer takes the fourth drop lane below instead of running. The boolean neither sets nor clears the commit withhold below, because a missing ledger answers False without ever reading the bytes. The same boolean now reaches the two out-of-band surfaces that answer decisions outside a sweep (DW-198): decisions.apply_pre_answer returns it instead of discarding it, so bmad-loop decisions replaces its per-decision outcome with one naming the non-write (and which of the two states it was, since it holds the ledger path) rather than printing closed now, and the TUI decision modal raises a warning toast naming the id and does not count the answer into recorded N decision(s). Neither treats it as an error: both walk on to the next decision and the command still exits 0. Nothing is withheld either — the pre-answer store write and the best-effort commit both still run — so a build/keep-open answer really was saved to the pre-answer store, which both surfaces say. Neither promises more than that: a sweep's triage is derived from the ledger's open ids, so an id the ledger no longer carries is never surfaced again and the stored answer is pruned as no longer open. The close half of that trade is no longer left to a later run either (DW-167). The interactive arm persists the answer and journals decision-answered BEFORE it applies the effect — deliberately, so the human's answer survives a crash — which left a crash in that window with <run>/decisions.json claiming effect: "close" over an entry the ledger still listed as open, and every consumer read that answer as consumed: the reload accepts it, pending filters the id out so it is never re-asked, and no materialization lane matches close. The write order is unchanged; the READ side now repairs it. Before the walk prompts, every id in this cycle's decisions whose stored answer is a close is checked against the ledger's live open set, and one still listed open is an effect that has not landed, so it is re-applied through the same _apply_decision_effect the prompt uses — sweep-decision-effect-reapplied names it, it counts toward the cycle's closures, and it feeds the same commit gate. "Still open in the ledger" is the only discriminator, so the mirror-image crash (the effect landed, the answer write was lost) adds no second decision: line. The gate is a REPAIR/WRITE read because it gates a write: an absent or undecodable ledger re-applies nothing and writes one sweep-decision-effect-unavailable per candidate id rather than one row per file, so each row names an id to chase. Scoped to ids this cycle still asks about, since pending's filter over them is the suppression being repaired — an id the fresh triage no longer raises has its own routing already. The gate is the FILTER and not the enforcement: it reads the open set outside record_decision's lock, and a snapshot cannot hold across the write it authorizes, so the walk alone passes require_open and the still-open predicate is re-checked inside the same locked read/edit/write as the mutation. A rival writer closing the entry in that window is therefore refused rather than given a second decision: line over a close already recorded — nothing is counted closed, and the row names the retired-but-present state distinctly from the missing-entry and missing-file ones. Every other caller — bmad-loop decisions, the TUI decision modal, the pre-answer applier, the sweep's own interactive arm — is unchanged and does not pass it, so a decision recorded on an entry someone else already closed still lands. The build half is narrowed rather than repaired (DW-200): a build answer whose record_decision reported writing no line now takes a fourth drop lane instead of materializing a bundle, so no dev session is spent on an id the ledger holds no entry for. Scoped to that False RETURN — a read fault retains the saved build, but dispatch still requires the decision phase to end without ledger doubt. What neither arm degrades is the PUBLISH itself: a mutator whose atomic write fails (ENOSPC, EROFS, a failed rename) raises deferredwork.LedgerWriteError — an OSError subclass, so bmad-loop decisions and the TUI modal keep their degrade — and both sweep sites re-raise it ahead of the degrade arm, because a repair write that failed is not a phase that closed nothing (observation may degrade, repair writes must raise). The decision-phase raise is safe for the same ordering reason the degrade exists: the answer is already in <run>/decisions.json. The far side of the publish is typed too: when the ledger lock's release faults after the body landed (Windows LK_UNLCK, os.close), ledger_lock raises LedgerLockReleaseError instead of the bare OSError that arm read as "nothing was written", and both sites re-raise it — the closure is on disk, and the sweep does not get to say otherwise.
  • Four of the sweep's run-dir bookkeeping I/O sites degrade the same way — the triage cache's metadata probe and write-back, and the run-local decision store's metadata probe and seeded write-back — so a fault at any of them no longer ends an otherwise healthy sweep. At the decision store's SEEDED write-back (atomic_write_text_confined of <run>/decisions.json after project pre-answers are adopted; DW-262) a directory planted at the store — silent at the S_ISREG probe — or a refused or redirected parent (UnconfinedWriteError, the #593 confinement refusal) journals sweep-decisions-store-write-failed (file, the dw_ids adopted this cycle, error) and the sweep carries on with the answers in memory, since a resume re-adopts them from the project store; the INTERACTIVE write-back deliberately stays bare, so a human's answer at the prompt whose write FAILS still stops the sweep loudly. And when the store's metadata probe or content read was refused with an OSError this cycle (DW-264): the SEEDED write-back is withheld (sweep-decisions-store-write-withheld naming the adopted ids whose answers stay in memory — a resume re-adopts them from the project store), because the bytes on disk may hold valid answers that merely could not be read and replacing them from an answer map that started empty would turn a transient refusal into permanent loss (the withheld check precedes the write, so a withheld write is never also reported as failed); and the interactive PROMPT is withheld outright (sweep-decisions-prompt-withheld with file, the pending dw_ids not asked and the refusal's error, plus an ATTENTION notice naming the file to repair): an answer taken there could not be persisted, has no second copy, and nothing reads a build back off the ledger's decision: line, so an answer held only in memory was lost by a crash before its bundle was materialized. The decisions stay pending and unquarantined, so the next interactive sweep over a readable store asks them. The triage cache's metadata probe uses stat() rather than is_file() so an OS refusal degrades uniformly across Python 3.11–3.14 to sweep-triage-reload-failed and a fresh triage, with an absent cache staying silent (DW-224); the cache WRITE-BACK after a validated fresh triage degrades to sweep-triage-cache-write-failed (unwritable: ...) and the plan is still returned and acted on (DW-247) — its own kind, since sweep-triage-reload-failed is a reader's row and overloading it would report a healthy triage as a corrupt cache. What a lost cache costs, and why the row is worth reading: a resume of that cycle re-triages, _publish_stranded_close finds no cache at the no-open exit and so recovers no stranded close, and bmad-loop decisions cannot reconstruct that cycle's decisions from it; a directory planted at the cache path is silently "no cache" at the read and lands on this same row at the write. Since DW-263 a cache whose READ faults is unlinked before the fresh triage (sweep-triage-cache-invalidated; a refused unlink journals sweep-triage-cache-unlink-failed and the fresh triage still runs), and the write-back is atomic (atomic_write_text_confined), so a refused or short overwrite leaves either no cache or a complete one — never torn JSON, and never the older plan for the next resume to replay, except where the unlink was itself refused or the cache faulted at the metadata probe (that arm does not unlink): there a refused write-back does leave the older plan in place. The write-back is also confined to the project that owns the run dir (DW-269): a link planted at any directory component below the project root (.bmad-loop/, runs/ or the run dir) refuses with UnconfinedWriteError (the #593 refusal) and lands on this same sweep-triage-cache-write-failed row instead of publishing the cache outside the project. Only the read-failure arm invalidates: the metadata-fault arm cannot reliably unlink what it cannot stat, and the validation-failed arm's bytes cannot be replayed as a plan at all. The run-local decision store <run>/decisions.json gets the same stat() probe (DW-248): a metadata refusal journals sweep-decisions-reload-failed (unreadable: ...) and the affected decisions take the pending/skip path instead of the sweep aborting over a bookkeeping read on 3.11–3.13 before saved answers can be consumed; absence stays silent.
  • The pre-answer prune refuses rather than wiping when the ledger will not read for a write: absent (DW-176), holding undecodable bytes (DW-182), or refused by the OS (DW-197). The open set it derives is the KEEP list for a store write, so treating any of them as an empty ledger would read as "nothing is open" and drop every pre-answer the human recorded — and commit the wipe. All three take sweep-preanswer-prune-refused, whose reason is one of three fixed tokens (ledger-absent, ledger-unreadable, ledger-inaccessible — there, undecodable, versus there and the OS refused the read, which are different operator repairs), with the decode or errno detail in error, and every stored answer survives to be re-offered next sweep. An empty-but-PRESENT ledger is not a refusal: it genuinely has zero open ids, so the prune still runs. A FOURTH token, ledger-in-doubt (DW-217), is the only one of the four taken with the ledger perfectly READABLE: the read succeeded, but this cycle already declared the ledger unfit to publish — a close batch or a decision effect that could not land — so the open set derived from those bytes is not a KEEP list to trust. It covers the decodable fault class the three reads above cannot see, where a half-landed write flipped an id to done without recording its line and the human's pre-answer for that id was pruned and the deletion committed. It sits BELOW the three read arms deliberately, so each of those keeps its own token and its own carry, and it carries nothing of its own — the latch it read already reaches the repeat boundary — and writes no error, since there is no fault to quote. The refusal degrades rather than raising because this prune is the LAST call of a cycle, so a raise would crash a cycle with nothing left to do over bookkeeping — including a cycle that deliberately ran no bundles at all, since the dispatch gate above withholds them when the ledger is in doubt.
  • The decision phase withholds its ledger commit when the LAST effect in the walk faulted, so the bytes an effect could not read are never published — and since that commit's pathspec IS the ledger, narrowing the scope bounds what else can ride along without making the withheld file itself committable (the withhold is unchanged; what changed is that a LATER commit in the same cycle can no longer undo it — DW-187). It also commits only when an effect actually LANDED: a walk that answered nothing wrote nothing, and spawns no git at all (DW-185). It is the last attempt's verdict, not the walk's: an effect that succeeds afterwards proves the ledger reads again and clears the doubt, and that commit then carries the earlier decisions' lines too — which matters because without --repeat there is no later cycle to pick them up, and this cycle's bundles need a clean baseline. The attended hand-back reports it: where every effect landed it still prints ✓ decisions recorded — sweep continues in the background, and where an effect did not land while final ledger doubt is clear — including a False return that leaves the doubt clear without a later success — it says the answers are saved, that not every decision reached the ledger, and to look for sweep-decision-effect-unavailable in the journal. If the last attempt faulted, it instead reports that the ledger is not fit to publish and needs repair, without promising background work or claiming bundles were already withheld. The hand-back itself is unconditional either way — its trigger is that nobody can be relied on to answer here any more, not that the effects landed. The withhold is only half the story, though, because it withholds only its OWN commit: bundles dispatched later in the same cycle commit through verify.commit_story / verify.finalize_commit, both of which open with a whole-tree git add -A, so a bundle running behind a faulted effect published exactly the bytes the withhold refused to publish (DW-194/202/210). A ledger left in doubt by the decision phase is therefore also a DISPATCH gate: _cycle runs none of that cycle's bundles, journals sweep-bundles-withheld (bundles_not_run, reason="ledger-unreadable") when there were any to withhold, and a repeating run ends on the existing ledger-unreadable stop below with repair instructions. Non-repeat and --decisions-only runs retain their existing early returns, with no repeat-stop record or new ledger-repair ATTENTION notification. Those two git add -A pathspecs are deliberately unchanged: narrowing them would bound what else rides along without making the ledger itself safe to publish, and the fix is that nothing corrupt is in the tree when they run. The gate covers both fault classes the withhold does — bytes that will not decode, where dispatch would otherwise die on the intent document's own ledger read, and a lock or write failure (OSError, ValueError, StateRootError) that leaves a half-recorded answer which reads back perfectly and would be swept into HEAD under a bundle's commit message. Materialization still runs ahead of the gate: its drop lanes are ledger-independent bookkeeping and feed the cycle's progress signal. The latch that gate reads is armed from every fault site that can raise the doubt, not just the two decision-effect handlers (DW-216/217/220). _close_resolved gets a latch of its OWN — it runs ahead of the decision phase, which publishes its verdict with = so a healthy phase can clear an earlier cycle's doubt, and folding the two would let that assign erase a close-phase fault — armed in its existing sweep-resolved-close-unavailable degrade and reset at the top of the phase, so it stays cycle-scoped. The DW-167 re-apply gate arms from its own read refusal, which it used to concede as a residual: it attempts no effect, but it OBSERVED the fault, and the class it observed is exactly the class that makes the bundle intent document's own ledger read raise. And a phase that attempted nothing at all — the commonest unattended shape, every decision skipped for a human — has no observation to publish, so it takes ONE ledger read it does not otherwise take and journals sweep-decision-ledger-refused (ledger, a fixed reason of ledger-unreadable or ledger-inaccessible, the fault in error) when it refuses. That probe is conditional on the phase having attempted nothing and observed nothing, which is what keeps it from masking the arms above it; ABSENCE arms nothing on any of the three legs, keeping DW-176's discipline. All three read the doubt through one helper shared with the repeat stop and the pre-answer prune, so a future arm cannot be wired into one reader and missed by the others. Since DW-218/219 that helper also reads a RUN-scoped mirror of the verdict, written to state.json at EVERY site that arms a latch — the close phase's degrade, the five arms inside the decision phase, and the decision phase's tail publish, which is a defensive no-op in every reachable state rather than a backstop that ever writes first — rather than at the gate or at the tail alone: the latches themselves are instance state, so a stop request observed in the withheld branch (which takes its item-boundary stop check ahead of the sweep-bundles-withheld row) or any crash in the same span ended the process with the verdict held only in memory, and the resume then dispatched — on a DECODABLE half-write every in-memory arm of the resumed cycle passes, so the bundle's git add -A swept the unauthorized flip into HEAD. Writing only at the tail leaves the longest span of all open, since the decision walk's own fault arm continues into an iteration that blocks on a human at a prompt. It tracks the latches it mirrors rather than simply outliving them: the two sites that clear the walk's local verdict — an effect that lands afterwards, proving the ledger reads and writes again — release the mirror too, unless the close phase's separate latch is armed this cycle, or the mirror was inherited across a resume: "the ledger reads and writes again" is a statement about THIS process's LAST ATTEMPT and nothing wider — not about a write a different phase made, nor about bytes a previous process left on disk. So the mirror IS clearable inside the run, but only for an arm this process made; those two exceptions are deliberately stickier and wait for the documented repair — a human edits the ledger and re-runs bmad-loop sweep, which is a new run with fresh state (a cycle whose doubt remains armed ends the run at its boundary; a cycle that releases its doubt can continue repeating with a clear mirror). A state.json written before DW-218/219 carries no such key and loads it False: a compatibility default, so such a run resumes exactly as it does today, not evidence that the run which wrote it had no doubt. The persisted verdict also reaches the three ledger publishers that run on a RESUME ahead of the dispatch gate (DW-246): the already-resolved close's own publish, its DW-193 stranded-close republish, and the post-recovery publish after in-flight bundles are re-driven all read the same one verdict and, when the ledger is held unfit, journal sweep-ledger-commit-withheld (message, file, and a fixed reason of ledger-in-doubt — the token the pre-answer prune already uses for a ledger that reads but is unfit) instead of committing; before that a resumed run that KNEW its ledger held a half-write published the whole file, half-write included, before the gate that exists to withhold it was consulted. A withheld publish is not a refusal — no target was probed and no git was spawned — which is why it is its own row rather than a fifth refuse_cause. In the other direction, a ledger-family sweep-ledger-commit-refused now ARMS the persisted doubt after its row (DW-244): a refusal after a landed decision effect used to leave every latch clear, so the cycle's bundles reached the bundle intent document's own ledger read and the run crashed on the same ledger the publisher had just declined; all four refuse_cause tokens arm, target-absent included — an absent tracked ledger cannot crash the intent read, but a bundle dispatched over it commits with a whole-tree git add -A, which would stage the ledger's deletion under the bundle's message, so absence is withheld on too, at the cost that the repair notice then names a file to restore rather than repair; a store-family refusal says nothing about the ledger and arms nothing; a refusal-armed doubt is released on the same contract as any arm this process made (a later landed effect while the close phase's latch is clear); and a refusal at the cycle-boundary publisher, which sits below the unfit stop, is honoured one cycle later — cycle N+1 withholds its bundles and ends on the unfit stop, unless one of its own effects lands and releases the arm. A ledger-family sweep-ledger-commit-unavailable from the publisher's RESOLVE arm — a Path.resolve that raises OSError/RuntimeError, the same unwalkable component the bundle intent document's own ledger read would meet — arms the same doubt after its row for the same reason (DW-260); the git-fault arm (a GitError after a successful resolve, the best-effort skip described below) arms nothing, since git declining to publish a file it could reach says nothing about the ledger's readability. And the sweep loop's nothing-open exit writes the ledger-repair ATTENTION notice (headline the deferred-work ledger is not fit to publish, naming the ledger path and the clean-worktree precondition) whenever the doubt is armed (DW-251) — the class that arms the doubt never writes a notice of its own, so a run that withheld publication and then found nothing open used to end on sweep-nothing-open / no-open with no repair instruction at all; the exit's rows and its return are unchanged. The nothing-open exit's stranded-close publisher (_publish_stranded_close) reads the same verdict as a fourth call-site gate since DW-250, above BOTH of its probes, and its withheld row alone adds dw_ids (the cached plan's already-resolved and decision ids it declined to prove); the post-migration publisher does not read the verdict itself because the migration it publishes is refused one gate earlier: _loop meets an inherited doubt ahead of _ensure_migration and takes the doubt's own ledger-unreadable stop and repair notice before any rewrite session is spent. Accepted residual, unchanged: the gate does not extend to Engine._maybe_auto_sweep's parent leg.
  • A bundle close whose mutator cannot read the ledger under its own lock pauses the run rather than crashing it (DW-280). Every deferredwork mutator takes its own locked read_for_write ahead of every write, so a ledger that turns undecodable or suffers an OS metadata/text-read fault (DW-279) inside that window raises from the mark_done_many_reopenable call itself with nothing flipped; the sweep's three such calls — the accepted-dev close, the review-leg reclose and the isolated close carry into the main checkout — used to let that raise escape bare (an isolated bundle crashed after its merge with the carry unlatched; an in-place one crashed at the accepted close with the session's work on disk, or at the review-leg reclose after an accepted review with dev and review work uncommitted in place). Each now journals sweep-bundle-close-refused (site one of bundle-close-locked, bundle-reclose-locked, bundle-close-carry-locked; dw_ids the ids the close was about to publish; reason ledger-unreadable for undecodable bytes or ledger-inaccessible when the OS refused the metadata/text read — the same split sweep-cycle-ledger-refused and sweep-preanswer-prune-refused make), writes the ACTION REQUIRED repair notice steering at the UTF-8 or at the path's permissions/storage to match, with the bmad-loop resume <run-id> route, and pauses at the story gate with the task's phase and recorded close intent untouched — so the existing resume arms re-drive the close with no session spent: the accepted-dev close replays off the persisted PROCEED receipt, the isolated carry replays through the unlatched-carry pre-pass, and the review-leg reclose takes the sweep's restart arm (the bundle re-driven from dev). The sweep's own route, beside sweep-bundle-close-carry-refused, not the engine's ledger-read-refused; the LedgerReadError catch includes the OS-read subclass LedgerReadFault (DW-279), while pre-lock probes and lock/write faults retain their raw OSError behavior. Terminal post-merge harvested carries share that sweep-owned row and story-gate lifecycle (DW-286): harvest-carry names the pre-read and harvest-carry-append-locked the mutator read, dw_ids is empty because the failed append has not assigned an ID; unreadable existing state may still contain a merged or deduped copy, and the durable reason and notice identify the harvested-deferral append. Repair + resume replays the composite carry with zero sessions, appending harvests before bundle closes; direct pre-terminal sweep defer carries and ordinary story carries retain the engine escalation route.
  • Decision answers and Git publication: answering, stale answers, store validation, publication eligibility, refusal causes, diagnostics, and recovery.
  • The default Markdown diagnostic dump displays scrubbed sweep publication and sweep-repeat-done records from the same bounded collection as JSON, including their identity fields and presence booleans.
  • Decision-bundle naming, and quarantine for a dropped decision (DW-118). A decision bundle whose own bundle_name is absent or discarded falls back to decision-<id>, which is a fallback and not a reserved namespace — a triage plan may legally author a bundle of that exact name, and _bundle_key would then alias both onto one task and one intent directory, losing the human's bundle without a record. The final name is therefore made unique against the bundles already materialized this cycle (plan bundles and earlier decision bundles alike): a taken name gains a bounded numeric suffix (-2 … -9, first free wins, journaled sweep-bundle-name-deduped with the resulting name). Exhausting the bound is the one point in name assignment at which a buildable answer yields no bundle — the decision is journaled as dropped (drop_cause: name-collision), notified with the contested name, and its entry stays open. Every drop lane — no-intent, name-collision, the keep-open lane's stale-option (DW-123), and the two ledger-fact lanes effect-unlanded (DW-200) and entry-not-open (DW-214) — also quarantines the decision for the rest of the sweep run, so a later --repeat cycle whose re-triage agrees with the answer again neither revives nor re-notifies it. Since DW-124 the quarantine is persisted run state (sweep_dropped_decisions on state.json, published immediately after the drop is announced), as is the unattended decision-skipped-unattended latch beside it (sweep_skipped_decisions): once state publication succeeds, pausing and resuming the SAME run no longer re-announces a disposition it already made, where before both were instance state and a resume started empty. Deliberately run-scoped and deliberately not the RUN-LOCAL answer: that answer stays on disk in <run>/decisions.json — auditable by design, and untouched by every drop lane — while a NEW run re-evaluates every decision from scratch. What the keep-open lane's drop does now take is the PROJECT-level store entry (DW-143; see stale answers): the quarantine is what keeps one run from re-announcing, and pruning that entry is what keeps the NEXT run from re-reading the same stale answer and re-dropping it forever. A state.json written before DW-124 carries neither key and loads both empty, resuming exactly as it did before.
  • Bundle keys agree on deferred-work ids, not just on names (DW-125). A bundle's task key is a pure function of (name, cycle) (dw-<name>, or dw<N>-<name> from cycle 2), and a cycle skips a bundle whose key already names a task that has reached a terminal phase — that skip is what makes a resume cheap. Names, though, are re-authored freely by each triage: lose <run>/triage.json on a resume and the regenerated plan partitions the still-open ids under whatever names it likes, so a bundle reusing a finished bundle's name used to be skipped with its work never run (the materialization-time uniqueness pass above cannot see this — it compares names against the CURRENT cycle's list, never against persisted state). The skip therefore now also requires the persisted task's dw_ids to agree with the bundle's. Agreement is SET equality, so a re-triage that merely reorders the same ids is still the same bundle and is still skipped; an EMPTY persisted list agrees with anything, since that is the shape a state.json written before bundles carried ids loads as, and reading it as divergence would re-run every bundle of every paused legacy run. Where the ids diverge, the bundle is new work that happens to want a taken name, and it gets the same bounded -2 … -9 suffix (first free wins, journaled sweep-bundle-key-deduped with the original and resulting names) — applied to the NAME rather than the key alone, so the key, the <run>/bundles/<dirname>/ directory, the bundle_name: line inside intent.md and the key→name round-trip a degraded intent rebuild runs all stay consistent. Exhausting the bound journals sweep-bundle-key-collision with the contested name and its ids and notifies the operator; the bundle is not run and its entries stay open for the next sweep, which is the loud direction — the alternative is the silently swallowed bundle this rule exists to prevent. Scoped to terminal tasks only: an in-flight task at the key still goes through the usual in-flight bundle recovery, never a rename. Where that recovery cannot finish the bundle and instead resets the task to PENDING for re-dispatch, the task additionally adopts the current bundle's dw_ids before its intent.md is written (DW-144), so the intent document, the task and the bundle_closes_intended the ledger close is derived from all name one set of ids — otherwise stale task ids can reject a dev result for the new bundle or supply the wrong ids to the ledger-close step. Adoption is unconditional; it is journaled sweep-bundle-dwids-adopted (with both the previous and the adopted id lists) only when the two diverge as sets, since a pure reorder is the same bundle by the rule above and is not worth announcing once per resume. An empty persisted list counts as divergence here — unlike in the skip rule, where it agrees with anything — because such a task genuinely has no ids and must take the bundle's. A DIVERGENT adoption drops the rest of the superseded bundle's state too (DW-162/163/165), since the task is no longer the one that bundle was minted for: its bundle_closes_intended (which would otherwise close ids this task never ran), its spec_file and restore_patch (which would make the dispatch prompt resume review of the superseded amended contract, and make _record_dev_spec — a no-op once set — refuse the replacement's own spec), and its spent retry and review counters (attempt, review_cycle, followup_reviews_spent). It also clears defer_reason and advances generation for fresh session ids, as a human-resolved re-arm does, so a replacement cannot inherit an exhausted review budget and force-converge or defer on its first round. resolved_redrive deliberately SURVIVES the clear: it records that a human resolved this task, not which spec the task owns. The clears are scoped to divergence alone — an agreeing or merely reordered re-dispatch is the same bundle the task already attempted, and keeps its budget, its spec ownership and its intended closes.
  • A persisted bundle intent document is graded against the task that owns it, not merely checked for existence (DW-164). task.bundle_file is written before the adopted ids are saved, so a crash between the two left a task reusing a still-present intent.md whose dw_ids: line named ids the task no longer carried; re-ordering the two writes only inverts that pairing, so the document's ids are instead compared against task.dw_ids as a SET and the document is regenerated when they disagree. Regeneration is the same degraded rebuild used for a lost file (triage prose replaced by a note that the verbatim ledger entries are now the contract), and sweep-intent-regenerated carries a regen_cause saying which of missing / dw-ids-mismatch / unreadable applied — a closed slug rather than a free-text reason, so bmad-loop diagnose renders the cause itself instead of a presence boolean. A document with no dw_ids: line at all cannot be graded and so regenerates too. An EMPTY task.dw_ids is not an authority here — that is the pre-dw_ids state.json shape, and grading a real document against it would trade the bundle's actual brief for a rebuild naming nothing — so such a task keeps whatever document it has. Since DW-269 intent.md is written through atomic_write_text_confined against the project that owns the run dir: a link planted at any directory component below the project root (.bmad-loop/, runs/, the run dir, bundles/ or bundles/<dirname>/) refuses with UnconfinedWriteError, which propagates as the write fault it is — no degrade arm, no ATTENTION notice (DW-243).
  • Auto-sweep at epic boundaries or run-end ([sweep] auto); a failed or paused child sweep is journaled + notified and leaves the parent running — including the SystemExit an unusable multiplexer or an unresolvable profile raises, which used to end the parent at exit 1 with an orphaned session (#600). A stop (bmad-loop stop) or a Ctrl-C delivered through the child is the deliberate exception and propagates, so a parent stays stoppable while a child sweep is mid-flight (#601). Accepted residual (DW-194/202/210): a per-epic auto-sweep that ends on the ledger-in-doubt dispatch gate withholds its OWN bundles, but the parent run continues, and the parent's next story commit takes the same whole-tree git add -A — so it can publish the dirty ledger the child refused to. Both legs need a ledger TRACKED in the code repository (not the default shape) for that to be reachable, and repeating children report the repair through the existing ATTENTION notification; non-repeat children add no repair notification. Closing this residual would require a new child-to-parent signal and parent end state. Second residual, from the same entry: the gate is evaluated once, BEFORE dispatch, so a ledger that goes bad DURING a bundle is outside its reach — that bundle's own commit takes the whole-tree git add -A and publishes it. Closing that would mean re-checking the ledger at every commit boundary rather than at the cycle's, which is a different mechanism from this gate; the sweep's existing per-cycle refusals are what catch it on the following cycle. A child sweep stopped by a ledger fault is a plain RETURN since DW-197, so the parent records it as a child that ran: the trigger is spent and sweep-auto-finished is journaled, where the same fault previously raised through the child and reached the parent's sweep-auto-failed arm (a failed entry in sweeps_refused, plus a notification). That is the correct record — the child finalized its run dir and reported its own stop with its own repair notice; after repair, start a fresh bmad-loop sweep because the finished child cannot be resumed — but it means the PARENT's journal no longer announces a ledger fault the child already announced, and bmad-loop diagnose on the child run is where the stop token lives.
  • A trigger is spent only once its child has actually started, so a refusal no longer consumes it (#501) — but nothing re-asks it either, since both call sites close their boundary within a few statements of the refusal. What the run keeps instead is a record: sweeps_refused (trigger → not-started / failed / dirty), surfaced by the end-of-run summary, bmad-loop status, status --json and bmad-loop diagnose, naming bmad-loop sweep (which needs a clean worktree) as the human-present follow-up.
  • Repeat mode (--repeat / [sweep] repeat): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable, hits max_cycles, or could not read the ledger — either because the pre-answer prune refused it (sweep-repeat-done reason="ledger-unreadable" on undecodable bytes — DW-182/186 — or reason="ledger-inaccessible" when the OS refused the read — DW-197 — both counting the cycle that completed) or because _loop's own top-of-cycle read did (DW-197: the read that drives migration and the whole write-bearing cycle, and the re-read after a migration; both journal sweep-cycle-ledger-refused with the same two tokens and the fault in error, then stop on that token counting cycle - 1, since that cycle did no work — the legacy-appeared convention). Bare, either loop read ended the run as CRASHED at the top of cycle N+1, discarding the report for cycles 1..N that had already completed. Every one of the seven stops (no-open, no-progress, max-cycles, legacy-appeared, ledger-unreadable, ledger-inaccessible, and no-selected — the repeat exit when the --only / --min-severity selector selects nothing on a cycle after the first) also writes its token as stop_cause beside reason (DW-201) — a closed slug rather than the free-text reason, which bmad-loop diagnose renders as a presence boolean, so a dump named no stop at all. reason is unchanged and still written. The no-progress and max-cycles stops, by contrast, now pass THROUGH the cycle-boundary ledger commit on their way out (DW-223): it sits above both returns, so a close whose own publisher already degraded on a git fault ahead of its commit attempt (a tree git could not read at that moment — a commit git was asked to make and refused ends the sweep instead) gets another best-effort publication attempt instead of being stranded solely by where the run stopped. That is also a widening — those two stops now reach git where they previously spawned none, and the ledger is published whole, so an out-of-band edit to it can ride along on the same accepted terms the nothing-open publisher carries. The prune-carried stops also WITHHOLD the cycle-boundary ledger commit, which is the point of them: that commit's pathspec is the ledger, so continuing would publish the bytes the prune just refused to read and the next cycle would crash on them anyway. The completed cycle's work is kept, the undecodable bytes stay dirty in the working tree for a human to repair, and, when file notifications are enabled and writable, an ATTENTION line says so — naming the ledger's own path, since implementation_artifacts is configurable to any absolute path and the ledger may be symlinked out of the project. Re-running bmad-loop sweep after the repair is the resume, but changes in the configured code repository (repo_root) have to be committed or stashed first: sweep checks that worktree for cleanliness. A ledger tracked only in an external repository does not affect this check. The journal stop record remains available even if notification delivery fails. They outrank no-progress and max_cycles (and ledger-inaccessible outranks ledger-unreadable, so the prune's own last-observed fault is what gets reported), and a run without --repeat (or under --decisions-only) is unaffected by the PRUNE's refusal: it stays a journal row, with no repeat-stop record and no ATTENTION line. _loop's own read sits above that early return, so a fault there stops any run. An ABSENT ledger is not one of these stops (DW-176) — the next cycle ends cleanly on no-open. deferredwork.read_for_write wraps OS metadata/text-read faults as LedgerReadFault(LedgerReadError) with the original OSError chained (DW-279); the sweep handles that subclass before the decode parent to preserve ledger-inaccessible, the original OS attribution, and the permissions/storage repair notice. Its stat + S_ISREG probe (DW-221) preserves this refusal on Python 3.14, where is_file() had reported refused metadata as absence. Accepted residual (DW-197): the degrade covers the three reads that GATE a cycle — the prune and _loop's two — and not the other read_for_write calls in sweep.py (_ensure_migration's validation and restore re-reads, and _run_bundle's read of the ledger for a fresh bundle's intent document), where an OS refusal still ends a run as crashed. Those sit inside phases whose own failure already ends the run, so degrading them would need a stop story of its own rather than this one's. A second accepted residual sits beside that one since DW-252: _run_bundle calls _write_intent bare, so a rival write that retires an entry between the keep-loop screen's read and the intent read raises MissingLedgerEntriesError and ends the run crashed with no journal row, where the old code briefed a thin document. The intent document's REGENERATION read on a resume, by contrast, PAUSES rather than stops (DW-243): _finish_inflight_bundles re-drives an in-flight bundle ahead of any cycle gate, and a bundle whose document was lost regenerates it off that read, which bare crashed the resume at that site. It now journals sweep-intent-ledger-refused (naming the bundle, the ledger, the same two tokens and the fault in error), notifies with the ledger path, a repair-and-COMMIT steer and the bmad-loop resume <run_id> route, clears the task's baseline pair and raises the story-gate pause on that task — no sweep-repeat-done, no new stop token, no new pause stage. A stop could not keep the re-drive promise: _loop's return is persisted finished, which resume refuses; the pause leaves the run resumable, and runs.unreadable_sweep_ledger fronts that resume until the ledger reads again. Not a doubt latch either — the regeneration read has no verdict of its own to arm; the recovery pass IS withheld whole under an inherited doubt (sweep-bundles-withheld), and the regeneration only runs when it is not. The dispatch gate above reaches the two-token sweep-repeat-done stop — a cycle whose decision phase left the ledger in doubt withholds its bundles and ends here, on the same ledger-unreadable token, rather than through a stop of its own (DW-194/202/210). Since DW-216/217/220 the close phase's own fault, the re-apply gate's read refusal and the end-of-phase probe all reach that same gate and therefore this same stop and token — no new stop was added, because the repair is identical. The "could not be decoded mid-sweep" ATTENTION headline still belongs to the prune's undecodable arm alone, since it is the only latch that observed bytes nobody could decode; the three new legs report the generic "not fit to publish" wording, and a persistent OS refusal still reports ledger-inaccessible through the prune's own latch, which is read first. Since DW-218/219 a resumed run reaches this same stop on the SAME token off a verdict its own cycle never observed: the doubt is mirrored into run state when it is armed, so a stop or crash between the arm and the gate's report resumes into the same withholding rather than into a dispatch, and the resumed cycle withholds and stops even though its own close and decision phases run clean. No new stop, token or journal kind was added — the repair is identical.
  • Ledger archiving (sweep --archive, optionally --before DATE to bound by close date; --dry-run previews): moves closed (status: done <ISO date>) entries to sibling deferred-work-archive.md (body preserved, an archived: <date> marker appended), leaving an id-preserving stub (status: done <date> + archived: <date>) so grep and closes_deferred cross-references keep resolving and the live ledger stays proportional to open work rather than all history. The stub keeps load-bearing field lines — gate: (validate's closed-gate report keeps speaking), origin:/source_spec: (the harvest-replay dedupe stays status-agnostic), live severity:/priority: metadata (reopened work remains selectable by severity), and the reopenable-close undo tail (a paused sweep's bundle rollback still works). Reopening an archived stub — what that rollback does — demotes its archived: stamp to archived-body: rather than dropping it: the entry is live work again, so the stamp would be a lie and its shape would strand the entry outside every future archive, but the body its close moved out is still in the archive file and that line is what a later triage follows to it. Deterministic, no LLM, atomic writes with crash-safe ordering (archive before ledger; a retry keyed on id + close date completes the move without duplicate bodies). Refuses while any engine run is live or its liveness is unverifiable — it is the one out-of-band ledger writer. When the ledger is tracked, the move is durable only once both files are committed; a gitignored ledger — the default shape — or an artifact dir outside the repo has nothing to commit. Unrelated to bmad-loop archive <run-id> (run-tarball archiving).
  • Sweeps are their own resumable runs (bmad-loop resume <id>). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: bmad-loop resolve <id> --restore-patch <path> re-arms the bundle spec to in-review and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing.
  • Ledger writes serialize across processes (#286/#469). Every orchestrator mutation of deferred-work.md — an append, a close, a reopen, a recorded decision, sweep --archive's two-file rewrite — holds an advisory lock for its whole read-modify-write, so a second bmad-loop run, a run plus a sweep, or a run plus the TUI decision modal can no longer both read, both edit, and let the last atomic write win (lost entries, silently reverted closures, two appenders minting the same DW-<n>); multi-row work is batched into one locked pass rather than one per row. The lock is a sidecar under the state root (<state root>/locks/<digest>-<basename>.lock), never beside the ledger, because the ledger is tracked by design and the engine stages with git add -A; it is keyed on the resolved path, so every spelling of one file contends on one lock while two worktrees' in-tree ledgers correctly get their own. Readers stay lock-free — every writer already replaces the file atomically, so a reader sees one whole version or another. The wait is platform-asymmetric: POSIX blocks, while Windows bounds it at roughly ten seconds and then surfaces contention as an error rather than proceeding unlocked. A dev or review session's own ledger writes are deliberately outside this — the orchestrator sequences its writes against the sessions it dispatches. Here too the hold covers every read that decides the published bytes rather than every read: a mutator whose work turns out to be a no-op — ids already done, a decision on an entry that is not there, specs that all dedupe, nothing eligible to archive, or no ledger at all — is answered from one advisory pre-lock read running the same pure decision helper the locked pass runs, and takes no lock (#736), so a replayed rollback or a sweep --archive over a ledger holding nothing closed cannot fail on a lock it had no write to serialize. Only a would-write-nothing answer is acted on; anything else, and any fault while probing, falls through to the hold. append_entries_published deliberately keeps no missing-ledger answer — an absent ledger there means create, which is a write. The rollback paths that span a git reset --hard cannot be covered by a lock at all, so each is instead compare-and-set — and the write is anchored on the ledger's committed blob at the run's baseline commit, the text that reset republished, read out of git before the lock rather than off a working tree a rival could have written inside the reset window (#735); a ledger the reset republishes no text for — untracked, configured outside the repo tree, or symlinked into it, where the committed blob is a target pathname rather than ledger text — has no baseline text to anchor on, so the sweep anchors instead on the rejected rewrite the attempt itself graded, while the engine's two restores decline to read a missing file there as the reset's own work. A post-reset observation still authorizes the skips — declining to act is safe whoever wrote those bytes — and never a write. Divergence, and an anchor no probe could derive — an unreadable blob or a failed path resolution, both journaled ledger-baseline-probe-failed, or a run with no baseline commit, which stands down silently rather than filing a row an operator would have to triage — degrade each site in its own direction rather than writing over a concurrent writer: defer-ledger-restore-diverged merges by appending the entries disk has since lost, ledger-restore-skipped-diverged skips, and sweep-migration-restore-diverged escalates for a human to re-run the sweep. And a failed commit reopens exactly the entries the story itself closed, journaling deferred-close-rolled-back with their ids and deferred-close-reopen-unmatched for an entry whose undo marker a foreign edit has displaced.

Answering decisions

Use bmad-loop decisions (or d in the TUI) to answer skipped or missed decisions reconstructed from past triage output. Only ids still open in the ledger and without a usable project pre-answer are offered. The most recent valid triage containing each decision supplies its options.

Writing the answer and publishing it to Git are separate outcomes:

Effect Ledger write Project store and next sweep
build Record a decision: audit line. Save the chosen option's full semantics in .bmad-loop/decisions.json; a later sweep can create a bundle without re-prompting.
close Record the audit line and mark the entry done in one transaction. Write no project-store entry; the closed id leaves the open set immediately.
keep-open Record a decision: audit line. Save the answer in the project store; it suppresses bundles for the id while it remains applicable.

The run-local <run>/decisions.json is a separate audit record. Interactive sweep answers live there, including close, and persist through repeat cycles and resumes of that run. Project pre-answers carry answers across runs.

If the ledger file or entry disappeared while the prompt was open, record_decision reports that no line landed. The CLI reports the non-write; the TUI warns and excludes it from its recorded-answer count. Both continue, and the command still exits 0. A build or keep-open answer is still saved to the project store, but an id no longer open may be pruned without being consumed. A close in this situation writes neither file and runs no Git command.

A publication refusal does not undo an answer already written. See publication eligibility for which files may be committed and diagnostics for the separate not committed to git: report.

Handling stale answers

Option keys are positions in a list that each triage may rewrite. Before using a stored answer, the sweep checks its label and effect against the current option selected by its key. This applies to project pre-answers and run-local answers, including answers carried into another repeat cycle.

If the option disagrees, none of its intent, label or bundle name is used. sweep-decision-option-mismatch records the mismatch without label, question or intent prose. A resulting bundle's decision note identifies an earlier triage instead of quoting a question the human did not answer. When the option agrees, the answer's own non-empty prose takes precedence; the option supplies missing or empty fields.

The effect determines what remains actionable:

  • Build: an answer carrying its own intent still supplies a bundle after an option mismatch. Out-of-band build answers normally have that intent because the project store saves the full chosen option. Without either its own intent or an agreeing option's intent, the answer is dropped and the entry stays open for a future sweep to re-ask.
  • Close: for an id still asked by this cycle and still open in the ledger, reapply the run-local close using the saved label. An agreeing option supplies resolution/intent; a vanished or mismatched option still closes the entry with bare closed by human decision and no rationale detail.
  • Keep-open: when this cycle re-asks the decision, the answer needs a resolvable, agreeing option. A renamed or vanished option drops the protection, making the id eligible for bundling again. If fresh triage does not re-ask the id at all, an existing run answer continues suppressing its bundles, unless already quarantined by a drop this run.

Every drop emits sweep-decision-answer-dropped, notifies the operator and uses one of five drop_cause tokens:

Cause Meaning and disposition
no-intent A build answer has no trusted intent; leave its entry open to re-ask.
name-collision A decision bundle cannot obtain a unique name, including the bounded -2 through -9 alternatives; leave its entry open.
stale-option A keep-open answer's option vanished or disagrees; remove its protection. A disagreement also emits the mismatch row; a vanished option cannot.
effect-unlanded This run observed the interactive build answer's record_decision return false; do not build from an answer whose audit line never landed.
entry-not-open The live ledger no longer holds an adopted build answer's id open, whether absent or already retired; do not build it.

All five drops count as repeat progress. The id is quarantined in persisted state.sweep_dropped_decisions, so the same run neither revives nor re-announces it after a later triage or resume. This bounds progress to one drop per id per run. The run-local answer and any ledger audit line stay untouched; a new run evaluates its answers anew.

For effect-unlanded, the observed non-write is first persisted in state.sweep_unlanded_decisions. That verdict survives an interruption before the drop can be announced. Announcing the drop removes it from this list in the same state publication that adds the quarantine. When both ledger-related causes apply, effect-unlanded takes precedence over entry-not-open. Older state files without these lists load them empty.

Only a dropped stale-option keep-open answer is immediately removed from the project store. If an entry was actually removed, sweep-decision-preanswer-pruned records the id and drop cause, and the store edit is published best effort. This makes the decision available again through bmad-loop decisions and prevents the next run from reloading the same stale protection. Other drops leave their project answers intact at this point; the ordinary end-of-cycle prune retires answers for ids no longer open.

Validating decision stores

Both readers use the same answer-shape predicate, parameterized by store. A project answer rejected by that predicate does not count as answered for bmad-loop decisions, so an otherwise eligible id is re-offered. Shape-valid answers dropped later for missing intent or name collision are outside this guarantee.

  • Each value must be a JSON object with a recognized effect: build, close or keep-open. close is accepted only from the run-local store; the out-of-band writer applies closes directly to the ledger and never stores them in the project file.
  • key and label must be strings when present, for every effect. intent and bundle_name must be strings when present for build only. Missing optional fields remain valid at this screening step; later materialization determines whether the answer has enough trusted intent.
  • Extra fields are not screened. In particular, resolution and answered_at are not trusted answer inputs. A resumed close takes its resolution from the agreeing current option, not from an answer's resolution field. Build-only fields on a keep-open answer are also ignored.

An unusable value is excluded from the in-memory answer map and reported as sweep-decisions-reload-failed, naming its id, store, field/type or effect problem without answer prose. Valid neighboring answers remain usable, and unanswered decisions return to the pending/skip path. The refusal itself does not repair or trim the file: run-store write-backs preserve unusable values unless replaced by a new answer. A project-store close likewise remains on disk until repaired; its rejection makes the id reachable to re-answer.

Whole-file faults have different dispositions:

Store/read result Disposition
Project store missing, unreadable, invalid JSON, invalid UTF-8, non-object, or a non-file load_pre_answers returns an empty map. This tolerant loader does not journal the whole-file fault.
Run store invalid JSON/UTF-8 or non-object Journal sweep-decisions-reload-failed; a later write-back replaces the corrupt file with the available answers.
Run-store metadata or content read raises OSError Journal the reload failure; withhold the seeded write-back for that cycle (sweep-decisions-store-write-withheld) and do not put the interactive prompt at all (sweep-decisions-prompt-withheld, ATTENTION notice; the decisions stay pending). The hidden bytes may contain valid answers, and an answer that cannot be persisted is not taken.

A malformed project value considered for seeding also produces the reload-failed row, with the project-store suffix. A missing run store starts empty; a non-regular run-store target does not pass its read probe. Seeded write failures and interactive write failures have distinct handling, described in run-directory bookkeeping.

Project-store writers serialize their whole read/edit/write transaction under a cross-process lock keyed on the store path. The ledger uses a different lock; callers must not hold both at once. Readers remain lock-free because writes replace the file atomically, and Git runs outside the locks.

A prune with nothing to remove, or a drop for an id absent from the store, takes no lock and leaves bytes and mtime untouched. An unreadable project store also looks empty to these no-op probes. Real writes can fail while acquiring the lock, for example when no state root can be derived or a Windows sidecar is contended; those write failures propagate.

Deciding what can be published

The out-of-band publisher and sweep publishers name the file they wrote, then resolve it and run Git from that target's parent directory. The repository is therefore the one holding the file: the project, a disjoint repo_root, or a repository reached through a configurable implementation_artifacts path or ledger symlink. The project itself need not be a Git repository.

Each publish is file-scoped. The sweep's dirty check and commit use only that file's pathspec, preserving unrelated in-flight changes elsewhere in the repository. The whole ledger file is published, however: unrelated edits inside that same file can enter its commit, including during recovery of an earlier close. Resolution and validation narrow the race before staging; they do not lock the target against a later replacement or disappearance.

Each publisher applies its own eligibility rule before the shared target guard:

Publisher Eligibility
Out-of-band answer Ledger only if a decision: line landed; project store only for build/keep-open, which wrote it. An empty operand list runs no Git.
Already-resolved close phase At least one close landed, or no close landed this pass but every already-resolved id in the plan is provably done on disk. Both arms honor ledger doubt.
Decision phase At least one effect landed and neither the phase nor the run retains ledger doubt. A no-write phase does not publish merely because the ledger is dirty.
Stale-answer or end-of-cycle project-store prune Publish the store only when the prune removed an entry. The ordinary prune first requires a readable ledger and honors ledger doubt.
Nothing-open recovery This cycle's valid triage cache must prove a landed close, with no ledger doubt. File dirt alone does not qualify; see recovery.
Post-recovery A nonempty in-flight bundle recovery pass and no ledger doubt; publish any resulting ledger dirt before triage.
Repeat-cycle boundary No landed-write gate: after passing the ledger-fault/doubt stop checks, publish any dirty ledger, including out-of-band edits in a skip-only terminal cycle. Non-repeat, decisions-only and no-open exits return earlier.

The prune's ledger refusal is about the ledger it needs to inspect, not the store being published. See ledger doubt for withholding and dispatch consequences, and target refusals for validation after eligibility.

Out-of-band operands publish separately, each in its own resolved parent, under chore(decisions): pre-answer <id>. A refused, ignored or non-Git operand cannot sink its sibling. If both files need publishing in one repository, this can produce two commits carrying the same message. The pair is not atomic: an interrupt or a hook failure can leave only one committed, and hooks may run twice. Both answers were already written on disk, where the next sweep reads them. Successful publication into two different repositories is an ordinary success and adds no warning.

Understanding publication refusals

verify.unpublishable_target is shared by out-of-band answers and sweep publication. Each caller declares the family, ledger or store; the guard does not infer it from a filename. A refused operand reaches neither the dirty check nor git add; other eligible operands can still publish.

refuse_cause Meaning Repair to consider
target-absent The resolved target is absent under the family's probe rules. Restore the intended file or correct its path.
target-not-a-file A present target has an unacceptable type, such as directory, FIFO, device or socket. Restore a regular file at the intended target.
target-undecodable The ledger's repair/write read could not decode its UTF-8 bytes. The store never emits this cause. Repair the ledger contents as valid UTF-8.
target-unreadable A read or metadata probe failed with an OS fault; out-of-band publication also uses this for resolution faults. Inspect the reported error and repair the path, permissions or storage.

The ledger guard takes the ledger's own repair/write read. When that answers absence, a stat() probe distinguishes a missing target from a present non-file. An empty, present UTF-8 ledger is publishable. This gate checks readability and type, not whether every ledger entry is semantically valid.

The store guard takes one lstat() of the resolved argument. It accepts a regular file or a residual link entry, and never validates the contents: invalid JSON or non-UTF-8 bytes in a regular store remain publishable. A store symlink to a regular file resolves to that file; a symlink to a directory is refused, and a dangling link resolves to an absent target.

The residual-link exception matters for symlink loops: on Python 3.13+ a loop can survive non-strict resolution as a link entry, which the store guard accepts while the ledger reader reports target-unreadable. On Python 3.11–3.12 the resolve itself raises and follows the publisher-specific fault route below. Store-write confinement still refuses writing through a link at the store's own name; publication eligibility is a separate check.

Probe classification also differs from resolution:

  • The ledger reader and store guard share deferredwork.probe_absence. ENOENT/ENOTDIR, Windows errors 21 (ERROR_NOT_READY), 123 (ERROR_INVALID_NAME) and 1921 (ERROR_CANT_RESOLVE_FILENAME), and a probe's ValueError for a non-encodable path are treated as absence.
  • Other OS faults, including EACCES, EIO, ESTALE, EBADF and ELOOP, are unreadable faults. Explicit stat/lstat probes expose these rather than relying on interpreter-dependent boolean path predicates.
  • Resolution runs first. Both publishers catch OSError, RuntimeError and ValueError (including encoding-error subclasses). The sweep journals sweep-ledger-commit-unavailable; a ledger operand also arms persisted ledger doubt. The out-of-band publisher reports target-unreadable with the error.

commit_paths has a distinct uncertainty policy: only ENOENT/ENOTDIR mean absence; other probe faults omit that candidate and raise if no usable operand survives. It deliberately accepts a missing tracked file as a deletion to stage. The publisher gates prevent an absent ledger from reaching that path and being committed away under a decision or sweep message. They also keep an undecodable ledger out of HEAD and prevent a directory pathspec from staging its descendants recursively.

Refusal is best effort and does not itself raise or roll back the on-disk answer. A sweep ledger refusal arms ledger doubt; the surrounding loop still applies its own read and stop rules. A Git fault ahead of the commit attempt — a tree git could not interrogate — is also reported best effort. A ledger commit git was ASKED to make and refused (a hook, an unwritable index, a full disk) is not: path_clean has already answered for the tree by then, so the publication itself failed, and the sweep ends loudly rather than letting the cycle's bundles run against the dirty baseline the commit was meant to clean. The store's two prunes keep degrading on an attempted commit (DW-160): the on-disk record is what matters for a pre-answer, and re-dropping later is cheap. Journal-write failures continue to propagate.

Reading publication diagnostics

Every sweep _commit_ledger journal row carries file, the lexical basename deferred-work.md or decisions.json, rather than an operator-chosen symlink target name. The resolved repo directory, when available, is the file's parent, which may be a subdirectory of its Git repository. A resolve failure reports the lexical parent because it could not obtain the resolved one.

Journal kind Meaning
sweep-ledger-commit A commit was made; the row includes its SHA.
sweep-ledger-commit-clean The path was already clean, or the commit found nothing to do after the dirty check. Nothing was published.
sweep-ledger-commit-refused Target validation refused the operand; refuse_cause gives one of the four tokens above, with error for decode/OS faults.
sweep-ledger-commit-unavailable Resolution or Git failed ahead of a ledger commit attempt (or at all, for the store); the row names the file, directory, message and error.
sweep-ledger-commit-withheld An earlier ledger-doubt verdict prevented a gated publication. Recovery probes can include the ids whose writes were not proved.

An ignored path can appear clean. The clean row does not distinguish ignored from already-published content, and no git check-ignore is run. It makes a publisher that ran and published nothing visible alongside successful commits.

bmad-loop diagnose retains publication identity fields and refusal tokens; free-text directory, message and error fields become presence booleans. Its default Markdown dump and JSON use the same bounded collection, including sweep-repeat-done records. See diagnostic output for the surrounding contract. For an affected sweep's raw error, inspect .bmad-loop/runs/<run-id>/journal.jsonl; see run state.

Out-of-band publication has no sweep journal. PreAnswerResult.publish_note adds not committed to git: to the CLI outcome and produces a TUI warning toast. Each entry names the lexical file and its refusal cause, or commit-unavailable for a Git failure. Git's error is collapsed to one line and clipped.

Publication refusals and Git failures do not change the recorded-answer count, exit code or continuation to the next decision; a ledger non-write is reported separately as described under answering. Follow the recovery steps to repair an unpublished answer.

Recovering unpublished decisions and bundles

Repairing an unpublished answer. Inspect the named file and refusal/error and repair its contents or path as needed. If its resolved target belongs to a Git repository, commit the intended file there. For an intentionally non-Git location, commit-unavailable is expected and the on-disk answer remains usable. An answer already written remains available even if Git failed.

Re-answer an unusable project-store value through bmad-loop decisions when offered. Choosing build or keep-open replaces it with a usable answer; choosing close records the ledger closure without writing a project-store value. A stale keep-open drop already removes the project entry so it can be re-offered.

Publishing a close left on disk by an interruption. The already-resolved phase can publish a pass that closed nothing when every planned already-resolved id is now provably done. It emits no sweep-resolved-closed row for that no-write pass. An absent id or unparseable status proves no write; file dirt alone does not qualify.

When the last open entry was closed, the loop would stop before reaching that phase. Immediately before its nothing-open stop, it therefore checks this cycle's on-disk triage.json (cycle 1) or triage-<n>.json cache. Either of two separate views of the per-id repair/write reader can authorize publication:

  • All cached already-resolved ids are done, reflecting a batch close.
  • Any cached decision id is done, reflecting effects applied one at a time.

These are separate tests combined with OR. An absent or unparseable id proves nothing for itself; it does not veto another decision id's proven close. Empty sets authorize nothing and take no read, and the decision set is read only if the already-resolved set did not prove a write.

The cache must validate and name at least one relevant id. A fresh sweep has no cache, and an earlier cycle's cache cannot authorize this cycle's publication. The recovery probe reads the cache directly without dispatching a session. Unreadable or invalid cache data journals sweep-triage-reload-failed; a ledger-probe fault journals sweep-resolved-close-unavailable. Both publish nothing and keep the same stop outcome.

Ledger doubt vetoes the entire recovery publisher before either proof — both terms, not only the decision one, since the commit is of the file and a stranded already-resolved close beside an unaudited done flip would otherwise carry it into HEAD — emitting sweep-ledger-commit-withheld with all relevant ids. An empty plan produces no proof read or withheld row. When publication succeeds its row precedes the stop; when doubt is armed the ledger-repair notice follows the stop.

See ledger doubt and repeat stops: a finished sweep requires a fresh bmad-loop sweep after repair. Persisted ledger doubt survives resume: repair the ledger by hand, commit or stash changes in the code worktree so it is clean, and start a fresh sweep. Under an inherited doubt the resume's in-flight bundle recovery pass is withheld whole — a bundle bmad-loop resolve re-armed is not re-driven, since its commit is a whole-tree git add -A, and is left in flight (journaled sweep-bundles-withheld, then named by the stranded-bundle warning) — and the legacy migration is refused on the doubt's own ledger-unreadable stop and repair notice before any rewrite session is spent, since it would normalize and commit the very bytes every gate below withholds. An ordinary paused intent repair resumes the same un-finished run when it carries no inherited ledger doubt.

Screening bundles against live open work. Before an adopted build answer creates a bundle, materialization consults the ledger's live open set. This is one lazy repair/write read per materialization, shared with the final check of plan-authored and decision-created bundles. It is skipped when no candidate reaches either check.

If the ledger is absent, undecodable, inaccessible or already in doubt, the screen is disabled and one sweep-decision-open-set-refused row names ledger-absent, ledger-unreadable, ledger-inaccessible or ledger-in-doubt. Decode and OS faults include error. This refusal neither writes nor runs Git, and does not itself arm ledger doubt. Existing answer dispositions and other dispatch gates still apply; unknown open work is not treated as an empty set.

With a usable open set, an adopted build answer for a non-open id takes the entry-not-open drop above. A bundle reaching the final check must have all ids open; otherwise it is skipped whole, never trimmed, under sweep-bundle-skipped with reason: entry-not-open and the non-open ids. failed-or-escalated-earlier and human-chose-keep-open checks retain precedence. A bundle skip has no drop_cause, adds no quarantine and is not repeat progress. A cycle containing only such skips stops on no-progress; its still-open work waits for the next sweep.

Restoring a paused bundle's intent. _write_intent refuses a missing ledger entry with MissingLedgerEntriesError before creating the bundle directory. A present but closed entry can still be reproduced verbatim. On resume, regeneration refuses an absent ledger or missing entries under sweep-intent-regen-refused, with reason: ledger-absent or entry-missing and the affected ids. Decode and OS read faults use sweep-intent-ledger-refused instead.

The refusal notifies with bmad-loop resume <run_id>, clears the task's baseline pair and pauses at run-paused, stage=story-gate. The task remains PENDING with its name, ids and attempt intact; the run remains un-finished, and no later in-flight bundle or fresh triage proceeds.

Restore the ledger/entries, repair read faults and commit the fix when the target belongs to a Git repository, then resume the same run to regenerate its intent and re-drive the task, provided it carries no inherited ledger doubt. See run state for the persisted artifacts.

Stories mode (folder+id dispatch)

  • Opt-in second story source ([stories] source = "stories" + spec_folder, or bmad-loop run --spec <folder>): drives the same loop off a typed stories.yaml (a bmad-spec Story Breakdown, sibling of SPEC.md) instead of sprint-status.yaml.
  • Dispatches each entry by folder + id (/bmad-build-auto Spec folder: <folder>. Story id: <id>., spelled with whichever primitive name resolves on disk); the story spec lands at <folder>/stories/<id>-<slug>.md and is read back by a deterministic id-keyed glob — no shared board to line-edit, no result-artifact mtime-scan.
  • Strictly linear schedule (list order, no depends_on); done skipped, non-terminal statuses resumed on re-dispatch, blocked/sentinel/ambiguous stops the run for resolve. bmad-loop run --dry-run --spec <folder> and bmad-loop status print the schedule/board (id · live disk state · checkpoint markers · title).
  • Preflight content-probe: stories mode requires a dev primitive new enough for folder+id dispatch, or the run aborts with remediation. Sprint mode keeps working with any installed version.
  • Sentinel recovery: a pre-planning-halt sentinel spec (<id>-unresolved.md / <id>-ambiguous.md) is auto-deleted with a preserved copy under the run dir on re-arm, matching the contract's delete-to-retry.

Gates & human checkpoints

  • Gate modes ([gates].mode): none (fully unattended) / per-epic (pause at epic boundaries, default) / per-story-spec-approval (pause after each spec for approval). Note: per-epic is inert in stories mode — the flat stories.yaml list has no epics, so the boundary never fires; use the per-story checkpoints (below) or per-story-spec-approval there.
  • Per-story checkpoints (stories mode): independent spec_checkpoint (pause before code to review the plan; approve → implement, or request a replan) and done_checkpoint (pause after the story commits; skipped when it is the last story). Additive to gates.mode — a story can pause twice.
  • Every mid-run pause is surfaced in the TUI: a per-run pause-kind badge, a global attention count, and a p viewer per stage (plan-checkpoint spec review, story-checkpoint summary card, escalation with story context, gate spec review) — all calling the same CLI code paths.
  • Retrospective handling (retrospective = never | notify | auto) and notification on epic boundaries.

Multi-CLI / multi-agent support

  • Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the CLI (CodingCLIAdapter + profile) and the terminal transport (TerminalMultiplexer) — tmux ships bundled (with an experimental native-Windows psmux backend alongside it), and external backends (e.g. the herdr adapter) co-install as packages that self-register (how), behind a pluggable seam that lets a new backend slot in without touching the engine. The CLI axis has the same seam: a new adapter class registers via register_adapter and arrives through the bmad_loop.adapters entry-point group, with its selecting profile through bmad_loop.profiles — so an out-of-tree adapter family needs no core edit either (see the adapter authoring guide).
  • The OS is abstracted by a registry of seams, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (register_multiplexer, with availability-aware selection: env var → persisted [mux] backend via bmad-loop mux set → platform default → first available platform match), the process-lifecycle ProcessHost (register_process_host — terminate/force_kill/is_alive/identity), and the hook interpreter (ProcessHost.hook_interpreter()); bmad-loop validate runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see Porting bmad-loop to a new OS.
  • Supported, E2E-verified: claude (reference), codex (≥ 0.139), gemini (≥ 0.46), copilot (GitHub Copilot CLI ≥ 2026-02 — the copilot binary, not the VS Code extension; agentStop turn-end, -i interactive launch, --allow-all-tools; pin a capable model — the free GPT-5 mini default is unreliable for multi-step skills).
  • Supported, E2E-verified over HTTP/SSE (no tmux window): opencode (OpenCode ≥ 1.18, profile opencode-http, alias opencode) — one headless opencode serve per session, SSE session.idle completion with an HTTP poll fallback, per-session server password, token usage read back over the API. Hookless ([hooks] dialect = "none", no hook registration). With no pane to replay, the run logs split three ways: a curated readable transcript in logs/<task-id>.log (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in <task-id>.server.out, and a structured SSE trace in <task-id>.sse.jsonl. Install the extra (pip install 'bmad-loop[opencode]'), auth once globally (opencode auth login), and set model as provider/model; the Unity plugin's window guards don't apply (there is no window).
  • Supported, E2E-verified (no token usage yet): cursor (Cursor CLI cursor-agent, verified against 2026.08.04 and 2026.09.02) — interactive launch, stop turn-end hook and sessionStart in a project .cursor/hooks.json (versioned file, bare {"command": …} entries; Cursor 3.x loads no hooks from a project file with no top-level version), skills in .cursor/skills/, snake_case hook payloads. Launches with --force --trust: the trust flag is required, because an interactive launch in an untrusted directory blocks on a workspace-trust dialog that --force alone does not clear and that seeding Cursor's own .workspace-trusted marker does not satisfy either. Since trust is granted per launch rather than per stored path, isolation = "worktree" works (unlike antigravity). [adapter] extra_args replaces the bypass flags, so it must keep --trust. usage_parser = "none" pending a transcript-schema probe. Verify with probe-adapter cursor.
  • Experimental, isolation = "none" only: antigravity (Google's agy ≥ 1.1.3) — -i interactive launch, Stop turn-end hook (flat handler in .agents/hooks.json, no SessionStart/SessionEnd), --dangerously-skip-permissions for unattended runs; usage_parser = "none" permanently — agy's transcript exposes no usage data (tokens live only in an internal SQLite/protobuf store). agy gates each workspace on an exact-path trustedWorkspaces entry and blocks on an interactive trust dialog, which --dangerously-skip-permissions does not bypass — so worktree isolation hangs (#169). Verify against your agy build with probe-adapter antigravity.
  • Per-stage CLI/model overrides: run dev on one CLI/model, review on another ([adapter.dev], [adapter.review], [adapter.triage]).
  • Reasoning effort: a free-form effort string on [adapter] and every [adapter.<stage>] table (values are provider/model-specific — high, max, … — so nothing is validated against a catalog), inherited exactly like model (a stage that switches client falls back to "" = provider default). Only the opencode-http adapter carries it: it is sent as the per-call variant on every prompt_async body the session issues (initial prompt and every nudge) and omitted entirely when empty, never through OPENCODE_CONFIG_CONTENT (whose only effort key, agent.<name>.variant, is inert unless that agent also pins a model). An unrecognized name is not rejected — OpenCode accepts the prompt and the session silently runs at the provider default — so spell it exactly as the model's variant list names it. The tmux CLIs have no channel for it and ignore it; bmad-loop validate reports policy.effort-unsupported (a warning, exit code unchanged) when a stage on that family sets it. The value never reaches argv, so config_digest is unaffected; run --dry-run shows it as effort=<value> beside the model on the hookless launch line.
  • Add a CLI without touching Python: drop a TOML profile in .bmad-loop/profiles/<name>.toml (binary, prompt template, bypass flags, hook dialect, native→canonical event map). A CLI that needs its own adapter class still needs Python — but not a core edit: the profile's adapter field names a kind resolved against the registry, which a co-installed package extends.
  • bmad-loop probe-adapter collects + sanitizes the data needed to finalize/add a profile (hook payload shape, transcript location/format, token schema): a zero-launch scan by default, opt-in --probe for live capture. See the adapter authoring guide.

For Codex, validate and probe-adapter ask Codex's read-only hooks/list API whether the configured SessionStart and Stop relays are enabled and trusted. Stale, missing, or unverifiable hook trust is a failing result. A worktree run uses a different directory, so validate cannot certify its future trust from the main checkout. A live probe checks its temporary hook directory before launch; a fresh directory without a Codex trust grant stops with a hook-trust diagnostic. Profile or stage arguments that can change Codex hook discovery make the trust verdict unverifiable rather than certifying a different launch configuration.

Budgeting & cost tracking

  • Mid-session per-session token budget (max_tokens_per_session, default 4M weighted): both adapter wait loops sample cumulative usage on the ~30s heartbeat and trip once on crossing, per session_budget_mode — warn (default) raises an ATTENTION + lifecycle breadcrumb only; enforce also sends a wrap-up nudge, grants session_budget_grace_s (default 240s) to finish, then terminates the session over_budget (ordinary retry→defer routing; an artifact flushed at kill time is still honored). Sampling is live-verified on claude and best-effort on other transcript-reading profiles (two independent unknowns there: whether the CLI delivers the transcript path early — until a hook event carries it the guard is inert — and whether it flushes usage mid-turn); the nudge into a busy pane is best-effort everywhere (the termination is the guarantee), and adapters with no mid-session usage signal (usage_parser = "none", Copilot's shutdown-only flush) leave the guard inert.
  • Per-story token budget (max_tokens_per_story, default 2M weighted, advisory) on the same cost-weighted total — cache reads counted at cache_read_weight (default 0.1, matching ~0.1x vendor billing). Cumulative spend is re-checked at every session boundary, so an overrun surfaces while the story is still running and regardless of how it ends. The first crossing raises one ATTENTION + desktop notice (story token budget exceeded: <key>) and a token-budget-exceeded journal entry carrying weighted, total and budget; the warning is latched per story and persisted, and nothing is terminated. Every operator-facing total leads with the weighted figure and labels both units — the run-finished summary and bmad-loop status each lead with <weighted> weighted and name the raw count as (<raw> raw incl. cache reads), the TUI pairs weighted and raw columns, and session-end entries carry tokens beside tokens_weighted.
  • Token usage read from each CLI's local session transcript (per-profile usage_parser), aggregated per story (bmad-loop status).

Configuration (.bmad-loop/policy.toml)

  • Single policy file written by init, stamped into the run at every engine start — run, sweep, resume — so it always describes the policy that process enforces (applies to new runs and resumes; editable live from the TUI).
  • Rewrites of it are confined and permission-honoring (#593, #597). Such a write walks the components below the project no-follow and lands through the directory handle that walk produced — O_DIRECTORY|O_NOFOLLOW opens with dir_fd on POSIX, NtCreateFile relative to the handle above with reparse points refused on Windows (win32_at) — so a symlink or junction planted at .bmad-loop/ is refused rather than followed; refusing a link at the file alone never covered its parent, and mkdir(parents=True, exist_ok=True) accepts a symlinked directory. A host with neither arm degrades to a documented check-then-write; init's one-time seeding of a missing policy predates any session and stays a plain write. A policy.toml an operator marked read-only is refused with a PermissionError instead of being replaced and left still reading 0444. The confined walk also covers story specs inside the checkout, park records, the decisions store, and the sweep's triage cache and bundle intent document (DW-269); the read-only refusal reaches further — story specs, sprint-status.yaml, park records, the decisions store, hook settings.json — but sprint-status.yaml deliberately keeps its symlink-following writer (an operator may keep the board behind a link) and the hook-settings and worktree-provisioning writers keep their own pre-existing link checks rather than the descriptor walk. The read-only refusal deliberately skips machine-minted state (run archives, stop requests, the config-digest stamp) — those are channels, not operator signals.
  • Sections — all 15: [gates], [limits], [verify], [notify], [review], [stories] (which planning pipeline drives the loop: sprint-status or a typed stories.yaml), [dev] (see below), [adapter] (+ per-stage [adapter.dev|review|triage]), [sweep], [scm] (worktree isolation + merge-back), [cleanup] (run-dir retention + disk reclamation), [plugins] (trust allowlist + per-plugin [plugins.<name>] config — e.g. the opt-in game-engine layer via [plugins.unity], off by default), [tui] (low_frame_rate for slow/SSH links; persisted dashboard pane sizes), [operator] (whether a dev session may park a story at awaiting-operator), [mux] (machine-scoped multiplexer backend choice).
  • [dev] skill names the inner dev skill the orchestrator drives. "bmad-dev-auto" — the generic upstream dev primitive — is the only accepted value; the field is retained as the seam for a future alternative dev skill, and any other value is rejected at load. It is not the name sessions are dispatched with: upstream renamed the primitive to bmad-build-auto, so the invoked name is resolved from what is actually installed and a project on either era works with this field untouched. It has no entry in the core settings schema, so it is edited in the file rather than from the TUI settings editor.
  • Tunable limits: max_review_cycles, max_dev_attempts, artifact_file_max_mb, artifact_payload_max_mb (binary MiB, exactly 1,048,576 raw bytes each), max_followup_reviews, session_timeout_min, git_timeout_s, teardown_grace_s (one shared budget bounding the verified window kill and the follow-on reap of any straggler descendant the session detached — e.g. a setsid background writer — combined; whatever remains after the window dies is what the straggler reap gets, before the worktree is merged and removed), stop_without_result_nudges, dev_stall_grace_s, dev_stall_nudges, dev_stall_nudges_cap, workflow_stall_nudges_cap, max_tokens_per_story.

TUI dashboard

  • Read-only observer + launcher (bmad-loop tui): runs table, expandable sprint tree (epics → stories/retro), severity-colored deferred-work ledger, per-story phase table (phase · agent · dev attempts · review cycles · tokens · commit/defer), a run header naming the live-or-configured active agent, tabs tailing journal / pane log / ATTENTION.
  • Launch & manage from keys: start run/sweep (r/s), resume (e), resolve escalation (R), answer missed decisions (d), attach (a), cleanup (c), validate (v), settings editor (g), theme/mode toggle (M), quit (q).
  • Resizable panes: every boundary is drag-adjustable by mouse (the divider bars double as the Sprint / Deferred Work section headings) or a ctrl+w keyboard resize mode; sizes persist per-project to [tui] in policy.toml and re-apply on the next launch.
  • Survives TUI exit/crash: runs launched from the TUI are detached bmad-loop processes in a dedicated bmad-loop-ctl tmux session (per-project name suffix on psmux); the dashboard watches purely via run-dir artifacts, so shell-started runs appear identically.
  • Comment-preserving policy editor (g): grouped form, sections collapsed by default with one-line descriptions (ctrl+e toggles all), validated with the engine's own parser, unset keys show defaults as placeholders.

tmux session management

  • Each run drives agents in a dedicated bmad-loop-<run-id> session; attach to watch live.
  • Auto-teardown on finish (cleanup_session_on_finish, disable to inspect); a hard stop always kills it, a graceful stop --graceful tears it down under the same cleanup_session_on_finish gate a normal finish uses; paused/interrupted runs keep the session for resume.
  • bmad-loop cleanup (or c in the TUI) sweeps leftover sessions/windows for finished/stopped/orphaned runs of the current project; live runs, and anything belonging to another project, are never touched.
  • --json emits a stable machine-readable document per the contract below (schema-versioned; the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a dry_run flag) instead of the text. ctl_windows is a three-way partition — removed (verified gone after the kill; under --dry-run it is the would-close plan), survived (still listed) and unverifiable (the liveness listing itself failed) — because killing a window is best-effort and reports nothing; a survivor is retried by the next cleanup, and text mode marks the stdout count and names both non-removed arms on stderr rather than counting them as removed. If the candidate scan reports a failure, no window was chosen or killed: the arms are empty and ctl_windows.scan_error carries the reason (text mode puts it on stderr), so a reported preflight failure is never document-shaped like "nothing to prune". (scan_error carries the failures the scan can see; the multiplexer seam deliberately degrades a listing transport fault to an empty listing, the same documented ceiling the post-kill verdict accepts.) Exit stays 0 either way — the verdict is the text/document, not the code. sessions.removed keeps its older, weaker meaning: an attempted kill. Plan and outcome share one schema — same fields, same meanings, with dry_run saying which one you are holding — so a script can pre-flight a sweep and compare it against what actually happened. (Values are each invocation's own sample, not a promise the two agree: a live session can die between the preview and the real run.) The unverifiable-pid warning, which text mode writes to stderr, becomes sessions.unverifiable_pid in the document, leaving stderr empty.

Disk reclamation ([cleanup])

  • bmad-loop clean reclaims disk (distinct from cleanup, which is only tmux). It tears down git worktrees a mid-flight stop left mounted — the main accumulation source: each carries a real Unity Library/ (incl. the MCP-server build), which git worktree remove cannot reach once the engine was killed before teardown. It then trims the heavy worktrees/ tree from runs kept for history (the run still lists in the dashboard — discovery reads state.json, not the worktree), and archives or deletes runs past the retention window.
  • It also collects the out-of-tree half of a run. Removing a run dir no longer removes everything the run owns (#494), so delete/archive/clean remove the run's control-plane dir under the state root too, and clean additionally sweeps this project's orphans there — subtrees whose run dir is gone, from a hand-removed run or a delete that predates this. The sweep keys on the run directory existing, not on its state.json parsing, so a corrupt run an operator is trying to recover keeps its control plane; a trimmed run keeps its own for the same reason (it is still resumable). Their bytes are not in the reclaim estimate — a state dir holds consumed event files, the run's config-digest (#498), and little else. Known limit: the state root is keyed by the project's resolved path, so a project that is deleted, moved or renamed leaves its old subtree unsweepable — after a move the project keys somewhere new, and no project can name the old key. A move does not cost the run its config-change baseline, though: state.json carries a second copy that travels with the run directory, and resume falls back to it exactly when the out-of-tree file is out of reach (#498).
  • Safe by construction: only finished or stopped runs are touched; running, unknown-host, paused and interrupted (resumable) runs are never reclaimed. --keep <run-id> protects a specific run (e.g. a finished one whose Editor is still live), --dry-run previews, --retain N/--hard tune the window and archive-vs-delete.
  • Delete/archive and resume serialize on the same per-run lifecycle lock. Cleanup re-checks engine liveness after acquiring it and holds exclusion through archive snapshot/publication, run removal, and control-plane cleanup; a provably live engine refuses even under --force, while unverifiable liveness remains warn-and-proceed. If cleanup wins first, a waiting resume re-checks existence inside the hold and reports the run missing without recreating files or constructing an engine. clean records a racing refusal per run (protected when untouched, trimmed when earlier reclaim steps already ran) and continues with sibling candidates; the TUI reports the refusal and keeps the run visible.
  • --json emits a stable machine-readable document per the contract below (schema-versioned; the effective retention policy, freed_bytes as a raw integer, and the paths and run ids under worktrees/trimmed/archived/deleted/protected, and state_dirs_swept as a count) instead of the text. Plan and outcome share one schema, with dry_run saying which one you are holding, so a script can pre-flight a reclaim and compare it against what happened — though values are each invocation's own sample, not a promise the two agree. It names every item the text only counts or renders, and the unverifiable-pid warning text mode writes to stderr becomes unverifiable_pid in the document, leaving stderr empty.
  • Prevention is automatic: every run/sweep start reconciles worktrees leaked by a prior finished run ([cleanup] auto_clean_on_finish), and the Unity plugin's post_run hook removes the IvanMurzak MCP server's downloaded /tmp/<company>/<product>/*.zip and truncates its unbounded editor log ([cleanup] clean_tmp). For recurring housekeeping of stopped runs, schedule bmad-loop clean.

Setup & install

  • bmad-loop init installs the three bmad-loop-* skills (bmad-loop-setup, bmad-loop-resolve, bmad-loop-sweep, into .claude/skills/ and/or .agents/skills/), an absolute hook registration for the installed relay, .bmad-loop/policy.toml, and a gitignore covering the runs dir, plugin caches, and policy.toml itself (per-machine config). Flags: --cli (repeatable), --no-skills, --force-skills.
  • bmad-loop validate preflights every prerequisite: BMAD config, plugin manifests (plugins.manifests: every bundled and project-local plugin.toml — project-local read from the configured repo_root, the tree a run loads them from — is parsed exactly as a run will parse it, and never imported — a malformed one is a problem naming the manifest, so it fails here instead of at engine start after the run is published; a third-party manifest on an unsupported api_version is a warning, since a run skips it — #765), sprint-status, git (including its version — a host below the 2.34 support floor gets a git.version problem and exit 1, so validate's verdict cannot disagree with run/sweep/resume's outright refusal), the selected terminal-multiplexer backend (listing all detected when more than one is registered), CLI binary (probed, not just resolved: a name that is on PATH but fails --version, typically a dead WSL/npm shim, adds an adapter.binary-unrunnable finding at warning severity — adapter.binary itself still reports ok, and validate's exit code is unchanged — #294; only packaged profiles are probed — a project overlay's binary is resolved but never launched, so a clone cannot choose which binary this diagnostic launches; resolution still goes through your PATH, so what a probed name resolves to is whatever the session launch would itself run), hook registration, and the review skills the installed dev primitive actually invokes (reporting which name it resolved) — derived from its customize.toml review layers (or from step-04-review.md on releases that name reviewers inline), so both the merged bmad-review topology and the standalone-hunter one validate, and configured layers naming an uninstalled skill are caught — plus its customize.toml.
  • Where the artifact paths come from (#769, #154): implementation_artifacts, planning_artifacts, output_folder and repo_root are read from BMAD's central TOML — _bmad/config.toml, _bmad/config.user.toml, _bmad/custom/config.toml, _bmad/custom/config.user.toml, each overriding the one before, merged as BMAD's renderer merges them — and from the legacy _bmad/bmm/config.yaml. Each key is looked up the way the renderer resolves a short config key: a key found under more than one table (e.g. both [core] and [modules.bmm]) is refused as ambiguous, naming every location. A TOML value wins over the YAML for every key; the YAML only fills a key the TOML lacks, and with no TOML layer the YAML is read as before. A malformed or non-UTF-8 layer, a blank or non-string value, or an ambiguous key is a bmad-config failure, never a silent fallback to the YAML. The two artifact dirs are required; output_folder defaults to {project-root}/_bmad-output and repo_root to the project dir.
  • The preflight also names the multiplexer selection reason wherever selection resolves (mux.selection, e.g. platform default for win32), not only when a BMAD_LOOP_MUX_BACKEND/[mux] backend choice forced it. A fallback selection is reported as a warning (its own label says no available backend matches this platform); a selection that outright failed is carried by mux.preflight, and a detection that failed by mux.backends-detected at warning — so a missing mux.selection line is normally explained by another finding (the historical unregistered-tmux fallback is the one silent exception; see the --json contract note in documents.py). On top of that, host.win32-on-wsl-path warns when a native-Windows interpreter is working on a \\wsl.localhost\... project (#332 — see multiplexer-backends.md for why WSL can hand a bash prompt the Windows build). Both are diagnostics only: neither changes which backend is selected (psmux is correct for a win32 interpreter) and neither flips validate's exit code. bmad-loop diagnose carries the same two facts in its Environment block as sys.platform and win32 on WSL distro path (yes/no).
  • Non-invasive: drives the upstream dev primitive unmodified — there is no fork to keep in sync — and review is just a re-invocation of it on the done spec. Your standard BMAD install is never modified.

Command reference

  • bmad-loop init — install skills, hooks, policy, gitignore.
  • bmad-loop validate — preflight all prerequisites. --json instead emits a stable machine-readable document (schema-versioned; the ok verdict, the queue mode/spec_folder, per-severity counts, and every check as a flat emission-ordered finding with a stable check id, severity, human message and structured detail) per the contract below; a failing check still emits the whole document, at exit 1 — the nonzero code is the verdict, not a failure to produce one.
  • bmad-loop mux — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why; a backend whose binary is present but crashed the version probe gets a warning: on stderr carrying the probe's own failure, since the - in the VERSION column cannot tell that apart from a binary that reports no version); on a backend with a registry namespace it also prints the registry root this project's sessions live in plus the export that reaches them from a bare client — on psmux that is <state root>/<project>/_mux, so a plain psmux ls shows none of them and says so rather than erroring (#537); mux set <name> persists a machine-scoped choice into policy.toml (--clear reverts to auto, --force allows a name only registered on the target machine). Bundled backend: tmux; external backends (e.g. the herdr adapter) register via the bmad_loop.mux_backends entry-point group — see Terminal multiplexer backends.
  • bmad-loop adapters — list registered coding-CLI adapter kinds (name · builtin/external · whether the family drives a multiplexer · which profiles select it), the CLI axis's counterpart to mux. Unlike mux there is no global choice to persist: a kind is selected per profile by its adapter field. A profile referencing an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a warning: on stderr; validate reports the same as adapter.kind / adapter.external / adapter.external-profile.
  • bmad-loop run — drive the dev → review → verify → commit loop.
  • bmad-loop sweep — triage + execute open deferred-work entries.
  • bmad-loop resume <run-id> — continue a paused/interrupted run. A resume is fresh intent, so a stop request the prior run left behind is discarded first, in either mode — and if it cannot be removed, resume refuses and names the file rather than re-arming into a run that would stop again at its first item. Resume also rendezvouses with delete/archive on the run's lifecycle lock; when cleanup removed the run while resume waited, it reports no such run before any state helper can recreate the directory. A sweep run whose deferred-work ledger bytes do not decode is refused at entry, before the run is armed at all: the refusal names the ledger path and the decode fault, and steers to a hand repair plus a fresh bmad-loop sweep in a clean worktree, noting that the paused run stays resumable (so its in-flight bundle recovery is not thrown away). It is not the only answer these runs can get, and where a better one exists it stands down for it: a finished run still gets already finished, and a run whose id aliases the control session's own keeps that refusal and its bmad-loop delete route. (It also stays silent whenever it cannot answer at all — no run state, no project config — so the fault's own owner reports that instead.) Where it does fire it comes FIRST — displacing the git-floor, worktree-isolation, base-skills and no such run refusals, since it is the cheapest and most actionable of the set and each of those is reachable again on the retry — but a provably-live engine still wins outright and the ledger is never probed. Both fault classes are refused, each with its own repair: a decode fault steers to a hand repair of the bytes, and a read the OS refuses (permissions, storage) — since DW-234, which reversed the DW-204 resolution's frozen exclusion of it — steers to repairing the path's permissions or storage — that attribution is the gate's own; the bmad-loop sweep route it pairs with is the one the sweep run's own ledger-inaccessible stop gives; every other clause (the path, the bmad-loop sweep route, commit-or-stash in a clean worktree, stays resumable) is shared. deferredwork.read_for_write wraps OS faults as LedgerReadFault (DW-279); the gate handles that subclass before the decode parent, retaining the original OS attribution and permissions/storage repair route. Nothing is armed or re-armed either way. Story runs are out of scope — the gate never looks at them — and that decline is safe rather than merely decided: since DW-231 (a decode fault) and DW-258 (a read the OS refuses) the engine routes both fault classes at its own four direct read_for_write sites — and, since DW-259/DW-279, both fault classes at the mutators' locked re-reads, the salvage refile and the close carry included (see the harvest bridge above) — so a story run over the same ledger no longer crashes at them. bmad-loop resolve and the TUI's re-arm gesture take the same probe at their own entries — ahead of resolve's interactive session and ahead of either surface's re-arm — so an escalated sweep over an unreadable ledger is refused with its escalation still armed rather than spent.
  • bmad-loop resolve <run-id> — resolve a CRITICAL escalation, then re-arm + resume (--story, --no-interactive, --restore-patch <path> 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; it implies the listing and never prompts, and nothing pending yields a valid empty document.
  • bmad-loop confirm <story-key> — complete a story parked at awaiting-operator once you have carried out the external actions it owes: acknowledges each 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 it owes; --reverify re-runs your [verify] commands first and blocks on failure; re-running it on an interrupted confirmation finishes that confirmation. --json emits a stable machine-readable document per the contract below — per parked story the key, actions, spec file, spec/board status, the parking run and the commit carrying the park (empty until the record is in a commit), plus derived confirmable/resumable flags, the confirmation_recorded reading behind the latter, and a human drift reason; 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; an empty runs dir yields a valid empty document.
  • bmad-loop status [<run-id>] — 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 — the supported surface for scripts; the text output is best-effort.
  • bmad-loop diagnose [<run-id>] (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 instead of the markdown report.
  • bmad-loop attach [<run-id>] — tmux-attach to a run's live agent session.
  • bmad-loop stop <run-id> — stop a live run. The default is a hard stop: stop now, abandoning the in-flight item and killing the agent session. --graceful instead requests a graceful stop — the engine finishes the in-flight item (a story through commit, a sweep bundle through commit, or an in-progress sweep triage — after which no bundles start), then finalizes cleanly and stops as a resumable stopped run, suppressing any pending auto-sweeps; --cancel-graceful withdraws a pending request. Both modes ride the same stop-request.json control file, which carries the mode: a hard stop lodges mode: "hard" before it signals, and that atomic, project-confined write is also what supersedes a pending graceful request. The engine honors a hard request at the next item boundary and mid-session, where each adapter's wait loop polls it twice per iteration — before and after the loop's own up-to-5s wait — so a quiet session normally lands the stop well inside the 10s grace window. That is the common case rather than a bound: an iteration blocked on a transport call, or waiting out RESULT_GRACE_S for an artifact, can exceed the window on either adapter before the next poll — an in-flight socket read or tmux call cannot be interrupted from the polling thread, so no placement of the check makes the interval unconditionally short. What the file does guarantee is reach: a hard stop lands on every platform and multiplexer backend, including one where an inter-process signal is never delivered at all (#319). A nested auto-sweep runs inside its parent but mints its own run dir, so it also polls the owning run's channel: stopping the parent stops the child mid-session rather than leaving it to the force-kill backstop. The child's read of the parent channel is hard-only — a graceful stop already keeps a child sweep from starting, and lets one already in flight finish. Teardown is unbounded on top of that — the opencode HTTP adapter then asks the server to abort and to report usage, and a server that will not answer those leaves the stop to the force-kill backstop, exactly as it would have before #319. SIGTERM still goes out alongside it as the POSIX fast path, but it is no longer the mechanism; the engine stays the single writer of stopped, and the external force-kill + run-stop fallback=True past the grace window now marks a stop this tool had to finish from outside — a teardown that outran the window reaches it as readily as an engine that never read the request — where before #319 it marked every native-Windows stop. The TUI surfaces the same pair: x hard-stops, S requests a graceful stop.
  • bmad-loop delete <run-id> — delete a run directory and its out-of-tree control-plane dir (--force stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot remove a run a rival resume claimed after that stop; unknown liveness still warns and proceeds.
  • bmad-loop archive <run-id> — compress a run into .bmad-loop/archive and remove it, control-plane dir included (--force stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot archive a run a rival resume claimed after that stop; unknown liveness still warns and proceeds. The hold covers tar snapshot, durable publication, source removal, and control-plane cleanup. The tarball holds the run dir, so it carries no events/. It is staged through an exclusively created temp under a fresh unpredictable name per attempt, so a planted name is never followed or reused, the failure cleanup is provably its own, and a temp stranded by a kill cannot deny later attempts; the tarball is fsynced before the publish — the run dir is removed immediately after, so it is the only remaining copy. A published archive lands at mode 0600 rather than a umask-derived one (#591).
  • Removal refuses while a matching agent session is live that the project cannot prove is another one's, even when the engine is dead: for an untagged session the run dir is the last ownership proof cleanup can read, so removing it would leak the session (#419). A session tagged to another project carries its own proof and never blocks. Run cleanup first, having confirmed the session is this project's (attach): for an untagged session cleanup proves ownership by that same run dir, so two projects sharing a run id can prune each other's. Or pass --force, which removes anyway and kills nothing. clean leaves such a run untouched and reports it as protected. The check is an observation with a measured ceiling (#732): a listing that omits a live session reads as "no session", so removal proceeds. Two ways to meet it, both measured on psmux 3.3.8 — a has-session whose 500 ms connect does not land reaps a live session's registry entry, and until the server's registry maintenance re-writes it ls omits that session while exiting 0 with nothing on stderr (that maintenance runs on a nominal 5 s check in the server's own loop, so the window has no hard bound; 1.7 s was one measured sample); and a process whose PATH lacks the multiplexer binary reads every session as absent for as long as that PATH lasts, however long the server keeps running. The first is a successful query with an incomplete answer and the second is a missing binary, and they do not even look alike — a reap takes one entry, so its listing may still name every other session, while a missing binary yields nothing at all — but both reach the guard as the same verdict, this run's name is not in the listing, which is what a genuinely dead session produces too. So it accepts both rather than wedging every removal path behind --force. If a removal does strand a session, clear it with kill-session on bmad-loop-<run-id> — but neither failure leaves it reachable straight away: restore the multiplexer to PATH first, and after a reap wait for the server's next registry tick to republish the entry, or the target will not resolve. On psmux the sessions live in the per-project registry, so run it with the export bmad-loop mux prints. Confirm the session is this project's before killing, since the name carries no project — and note that attach is no longer the way to do it: it resolves the run directory first, which the removal just deleted. Attach in the multiplexer directly instead, and for an untagged session accept that the run dir it lost was its only ownership proof, so what is left is the session's own contents and what you know of the run.
  • bmad-loop cleanup — remove leftover tmux artifacts for finished/stopped runs. --json emits the sessions and ctl windows removed (or, with --dry-run, that would be) as a stable machine-readable document per the contract below.
  • bmad-loop clean — reclaim disk from concluded runs per [cleanup]: tear down worktrees a mid-flight stop orphaned, trim heavy worktrees/ from runs kept for history, archive/delete past the retention window, and sweep orphaned run control-plane dirs from the out-of-tree state root (--dry-run, --keep, --retain N, --hard). A run that resumes before its final removal is classified as protected or trimmed according to work already completed, and unrelated candidates continue. --json emits what was reclaimed (or would be) as one stable machine-readable document per the contract below, with freed_bytes a raw integer.
  • bmad-loop tui — the interactive dashboard (--low-frame-rate for slow/SSH links).
  • bmad-loop probe-adapter <cli> (collect-adapter-data) — collect + sanitize adapter-finalization data for a CLI profile; default zero-launch scan, opt-in --probe live capture.
  • Every command takes --project <dir> (default: current directory). Any <run-id> accepts a partial — the tail after the last -, shortened to any unique prefix.

Machine-readable output (--json)

A command's --json mode emits exactly one JSON object on stdout and nothing else — no trailers, no fenced blocks. Every document carries an inline integer schema_version owned by that command; evolution is additive-only, anything breaking bumps it. Errors never produce a partial or error document: the message goes to stderr, stdout stays empty, and the exit code is nonzero. A command reporting a verdict is different: it exits nonzero to carry the answer while still emitting its document (validate --json). So the rule is positive: parse non-empty stdout whatever the exit code, and take the verdict from the document's own field — ok on validate, which unlike rc separates "the checks failed" from "the command broke". Codified in src/bmad_loop/machine.py, it holds for every command taking the flag — status, list, decisions, confirm, validate, clean, cleanup, diagnose, probe-adapter (#195). On diagnose and probe-adapter, --json replaces the human report rather than appending; with --out FILE the document goes to the file, stdout stays empty, and the confirmation goes to stderr.