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 | 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 |
- 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-planninggenerates 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 nextready-for-devstory; advances by epic/story. - Two orchestrator processes can no longer interleave a board advance (#286/#469):
sprintstatus.advanceholds 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 secondbmad-loopprocess 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 asbmad-loop confirmagainst 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).
- Drives the upstream dev primitive (unmodified) in a fresh tmux session —
bmad-build-auto, orbmad-dev-autoon 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 syncssprint-statusand synthesizesresult.jsonfrom 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 Resultsection 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.
- 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 explicitrepo_root:when you set one underisolation = "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 commandcwd, 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 explicitrepo_root:. A broken build never reaches review or commit.
- The follow-up review is a re-invocation of the dev primitive on the
donespec — a fresh-context session with no anchoring bias from the implementer (BMAD-METHOD#2508 routes adonespec 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, sobmad-loop validatederives the prerequisite set per tree and both topologies run unchanged: on pre-6.11 sources the hunter layers hand off to upstreambmad-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 ownreview-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 finishesdoneand 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.0never 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 ownreview-timeout-salvageorigin.) (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, defaulttrue): setfalseto 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 todone— 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, defaultrecommended): when review is enabled, decides when the follow-up pass runs.recommendedruns it only when the primitive setsfollowup_review_recommendedon adonespec (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).alwaysruns it on every story (pre-0.7.0 behavior).
-
CRITICAL session reasons are composed uniformly for dev, review, fix, migration, and triage. Their complete detail remains in session results,
state.json,journal.jsonl, andstatus --json;ATTENTION, text status, summaries, the TUI pause header, and its resume dialog cap only the displayed copy at 2,000 characters, namejournal.jsonlas 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 theAuto Run Resultthat 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/retrystatus 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 arefs/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, agit log/git diffagainst 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-onlyline 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 aspreserve_refinstatus/--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-operatorinstead 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'soperator_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 andpark_asserted: truein the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced## Auto Run Resultmarker reportingawaiting-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 oroperator_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 aspark-proof-of-work-skipped;zero_diffreports whether the waived gate would have found non-excluded residue (truemeans none,falsemeans some,nullmeans 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 laterstory-awaiting-operatorevent. Parking is notify-only and never halts the run;[operator] enabled = falserestores the old two-outcome behavior, where such a story could only bedoneorblocked. -
A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as
plan-halt-proof-of-work-skipped. Itszero_diffuses 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 theready-for-devartifact verification passes and never substitutes for the independentresult.jsonplan_halt: truemarker 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 Confirmationaudit section, advances spec and board todone, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time;--reverifyre-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).validatewarns on drift in every direction (operator.registry-stale,operator.actions-malformed,operator.park-record-missing), andconfirmrefuses a drifted record. A park written before #356 lives in the machine-local.bmad-loop/operator-actions.json, whichconfirmstill 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
donewith the entry still pointing at it; re-runningconfirmfinishes 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-doneboard is idempotent.--list,--json(resumable,confirmation_recorded) andvalidate(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.yamlwith 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 atdoneorawaiting-operatoris 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 adonerow. Only the review prompt adds where to go instead: a story that cannot be finished without a human decision is finalized tostatus: blockedwith 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, becauseblockedhalts 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_resultandpre_commit_gateall fire inside that same window — as its own## Sprint boardsection appended after the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without theblockedredirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -
Typed escalations:
CRITICALpauses the run + notifies (desktop +ATTENTIONfile);PREFERENCEis 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 secondbmad-loop resolveused 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 aresolution.jsonnow 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'scontext.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 presentspaused_reasonas 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 theesc-uptocolumn ofbmad-loop diagnose's markdown task table, and asescalations_resolved_uptounder--json(that is the key to grep in a support bundle), which is what explains a short or emptycontext.jsonescalation 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_attemptsper 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 aDecision.reasonroutinely carries a verify-output tail that would otherwise spill intoATTENTIONand a desktop bubble; the untruncated reason stays in thedev-decisionjournal 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 (
shreports rc126/127; on Windows a missing tool is caught by itsis not recognizedmessage or by resolving the command's leading token, and a command naming a filecmdcannot execute — a.sh, or any extension outsidePATHEXT, which cmd hands to the file association and which exits0without running anything — is a fault rather than a silent rc0pass, #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-timeOSErrorcounts — 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'senv_fault_patterns(anAPI 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:claudeseeds 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 uncaptured503stays prose), so a story that merely writes about a provider error cannot trip them (#507);opencodeseeds a provider quota/rate-limit and connection pair (#323), matched against theopencode serveprocess's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by itsENV_FAULT_LOG_SUFFIX— the tmux pane capturelogs/<task-id>.log, or<task-id>.server.out(theopencode serveprocess's own stdout) foropencode-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_attemptsburned without a line of work. The tmux adapter now stampsproduced_workon every non-completed result:truewhen aStoparrived (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);falseotherwise. 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'sEnterconfirms the dialog's default and the pane grows with the echo and exit text, the loop's own keystrokes.decide_devPAUSEs aproduced_work=falseresult 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#489lost-session suffix survives inside it, and re-arm resetsattemptto0. The flag ridesdev-decisionbesidesession_vanishedand, whenfalse, every role'ssession-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, andstall_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 butsession_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 forusage_parser = "none") — stamps the age onheartbeat.jsonastranscript_idle_s(nullbefore a transcript is known), and — when the run's journal is attached, which the engine does for every adapter it owns — journals onesession-idle(task_id,idle_s,since_ts,threshold_s) when the age crosseslimits.dev_stall_grace_sand onesession-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 — and0disables 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 —SessionStarton theclaude,codex,geminiandcopilotprofiles, so it covers a session's first turn there;antigravityfires noSessionStartand names the transcript only on itsStop, so its first turn is invisible to the notice (the heartbeat'stranscript_idle_sstaysnulluntil then), and theopencode-httptransport 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
Stopor 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 orbmad-loop stop, an operatorkill-session, a server crash, the host sleeping. Both arecrashed, so the retry/defer reason an operator reads said onlydev 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), assession_vanishedondev-decisionandfix-decisioneither way, beside the routing each fed, on every role'ssession-endjournal entry when it is true (the conventionenv_faultalready uses there), and as asession-vanishedbreadcrumb insession-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_sessionmaps 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 scorescompletedand 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 toready-for-dev) and resumes.--no-interactiveskips 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 journalrearm-commits-probe-failedand 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_fileis 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'sspec_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 aconfine_rootcan 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 Resultsection. 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--resumedoes 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 thestatus: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 tononewhile 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-skippedis journalled withrefused = 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-unreachableholds 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 tosave_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 Resultagainst 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_sentinelalready 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-abortedis journalled from the rollback and echoed by both surfaces, carryingrestored(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), orunknown. 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 whosesave_statedemonstrably 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 asresolve'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 anESCALATEDtask 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-devinput 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 POSIXdir_fdfamily, and on Windows NT handle-relative opens (win32_at:NtCreateFilewith aRootDirectory,FileRenameInformationEx/FileDispositionInformationExwith 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 reportsrollback-owned-spec-restored, neverrollback-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 itsresolution.json; the orchestrator re-arms the spec toin-review(notready-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.
-
Off by default (
[scm] isolation = "none"— work in place on the checked-out branch, byte-for-byte the prior behavior). Setisolation = "worktree"and each story (and each sweep bundle) runs in its owngit worktreeon abmad-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_artifactsin the accepted spec's optionalartifact_deliverablesfrontmatter 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'supdate-index --assume-unchangedor--skip-worktreeon 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'smodules/, 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.gitstill 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'supdate-index --assume-unchangedbit 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, sincegit mergewrites 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 wheregit restorecleared 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.gitmoduleswithsubmodule.<name>.ignore = allhides a hook'ssubmodule update --init, its writes into the new checkout, a moved HEAD, and even a rewritten gitlink from everygit diffreading), and the squash leg's own commit must have sealed the treemerge --squashresolved (so a targetpre-commithook 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.gitignorechange 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'supdate-index --assume-unchangedor--skip-worktreeon a clean tracked file outside the incoming set, which status never lists — and the receipt'slstatidentity of the file behind every entry outside that set the index already trusted unread when it was armed (assume-unchanged or skip-worktree;Nonefor 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 committedpolicy.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'slstatidentity — 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, whichstatusnever 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, whichstatusanddiffare as silent about — with a tolerated stray an incoming.gitignorechange turned ignored left out, and, from the removal reading, every incoming path and every entry beneath one (git's own write clobbers an ignoredd/xwhen the commit puts the filed); 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.gitentry, directory, gitfile or symlink, which git lists in no reading at all (.gitis administrative, and a fresh directory holding nothing but one is passed over too), so a hook'sgit initbeneath 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.gitof 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 ownstatusnever reports (it names a submodule's modified and untracked content, never its ignored content), names a hook's write the checkout's own.gitignorecovers 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.gitthere, which nostatusreading 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) — beforeunit-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 thelstatidentity 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'ssubmodule update --initmakes 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 freshgit 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 useslimits.artifact_payload_max_mb(10 MiB by default), so neither memory norstate.jsongrows 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 asa: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 aunit-mergedrow 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 recordingrefused-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 outsideimplementation_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 leavesartifact_payloadunset and retains the source even withkeep_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 withkeep_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 mode0600; 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(storyor a sharedrunbranch;runforcesdelete_branch = false), anddelete_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-toleratednaming them. Before this, one unrelatednotes.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, becausemerge --no-ffrefuses it outright and a fast-forwardablemerge --squashfolds it into the story's commit — against a diverged target--squashrefuses 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 achore(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-toleratedrecords 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 correctivemerge-preflight-refusedbeside it, naming the same paths and carrying git's raw text (#623). Aper_worktreeengine Editor leaking this branch's own files into the main checkout is still auto-cleaned and journaledmerge-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-ffthat merged cleanly and was then refused at the commit — apre-merge-commitorcommit-msghook exiting non-zero, or acommit.gpgsignthat cannot sign. Nothing conflicted, so it leaves no unmerged stages, but it does leaveMERGE_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 -uleads and answers content, andMERGE_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 conflictedmerge --squashwrites three unmerged stages and conflict markers while creating noMERGE_HEADat all. Thesquashleg reaches the third shape through a different door:--squashitself 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 plaingit commit, where commit hooks andcommit.gpgsignrun like anywhere else, and a refusal there is the same third shape. That one is rolled back withgit 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-onlyis 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, noMERGE_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-widereset --hard HEADriding 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 — neithergit merge --abort(which exits 128 here anyway, there being no merge to abort) norgit reset --hardtouches 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-scopedgit 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, asquashmerge git refuses no longer discards your uncommitted work:--squashhas 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 theMERGE_HEADreading: 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 unreadMERGE_HEADskips 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 owngit status, instead of a probe failure impersonating a verdict. A reading can die on the far side of success too: amerge --squashthat 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 doomedgit committhat 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 statusand 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 torun_dir/failed/<unit>/changes.patch;failed_diff_max_mbcaps per-file untracked-file size (oversized skipped with a marker),failed_diff_unlimitedlifts 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. Withseed_adapter_defaults(default on) each loaded adapter's ownseed_filesare copied in from the main repo before the session launches, together with every non-hookless adapter's resolved hookconfig_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,envand 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) becauseESCALATEDis 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_seedadds 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'sseed_files/seed_globs, its hookconfig_pathandskill_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:NULandaux.jsonare devices rather than files, and.claude/skills.is created as.claude/skillsbecause 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 seededsettings.jsonkeeps 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 pinnedskip-worktreein the worktree's own index, so the local command never ridesgit add -Ainto 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'sgit 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_seedrel 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), andworktree-module-skills-dropped(#464 — a wheel-bundledbmad-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-sweepfrom sweep triage,bmad-loop resolve;bmad-loop-setuphas 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). Thepause_after_specgate 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 dirgit worktree adddelivers 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 undercore.autocrlfapplies 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 ascompared: 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'sgit add -Awould 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 isrearm-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, butworktree-seed-droppednames it — and the record is journaled naming the main-checkout spec path and the target branch.locatedsays which silence it was, and the two have different remedies:truemeans 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 producestrue, even if the bytes are present: check filesystem access and I/O health when the file is already delivered.falsemeans 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 aworktree-seed-droppedentry 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-scopedcore.excludesFilethat 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 removetakes it away, and the shared, permanent.git/info/excludeis never written. An explicitly emptycore.excludesFileis 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/ignorewhenever that file exists, which the fork prefers over the$HOME/.config/git/ignoreupstream resolves (#403). Seeding the$HOMEone 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 unanswerablegit versionincluded, keeps the$HOMEanswer. 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 yourgit add -Acan 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.worktreeConfigandgit 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 setsextensions.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 = trueorcore.worktreein the shared config) the shield is skipped instead. It is skipped the same way where the shared config already carries an explicitextensions.worktreeConfigthat is nottrue(#396): that line is an operator's own declaration, and enabling over it would rewrite it totruepermanently 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, sincefalse,off,noand0all read back asfalse. A valuelessworktreeConfigline is not that case: git reads it astrue, so the flag counts as already carried and the shield proceeds. Lines an older bmad-loop wrote into.git/info/excludeare 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 togit ls-files -ci --exclude-standardand 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/dirpattern. 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 pathmkdirnever 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_devthen 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, journaledboard-advance-carried— orboard-advance-carry-uncommittedwheregit addrefused 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) — orboard-advance-carry-refusedwhere the board is no longer a publishable file at all by the time the commit runs, naming which oftarget-absent/target-unreadable/target-not-a-filefired (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 addreads 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 achore(...)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) — andbmad-loop confirm, which has no journal, drops the offending operand instead, per operand, with onewarning: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-fileandtarget-undecodable(a ledger holding bytes nobody can decode — the ledger family's own cause, split out oftarget-unreadableso 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-unreadableis 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 theGitErroris raised (latch kept, the next pass retries) unless the ledger is one git cannot own, in which case it degrades toharvest-carry-uncommittedexactly as any otherGitErrorthere does; the other publishers, holding no latch, refuse it too. Before the split, an undecodable ledger arrived at that carry astarget-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, andsprintstatus.advancenever 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 throughadvanceand 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'sgit addoverwrites 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:advancewould 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 journalboard-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 journaledboard-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, sincegit addrefuses an ignored path outright. Scheduling is what depends on it:_pick_nextreads 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.advanceis 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 confirmleaves a gitignored board out of its commit (#577).git addrefuses an explicitly named ignored path and refuses the whole operand list with it, which would take the spec's flip todoneand 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-linewarning:on stderr naming the path and the cause, andconfirmstill 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_parallelis a validated knob clamped to1until parallel fan-out is built. Therepo_rootkey 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] commandsrun 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 withisolation = "worktree", which seeds fromrepo_rootwhile the preflight probesproject;validatereports 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.
- A first-class plugin system extends the orchestrator without touching the core loop. A plugin is a folder-drop
plugin.tomlmanifest (under.bmad-loop/plugins/<name>/, overlaying bundledbmad_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 atpost_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 persistedshareddict — no new abort path. Distribution is folder-drop now, with a documentedbmad_loop.pluginsentry-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/.
- 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_modeis coupled to[scm] isolationbecause a live Editor MCP can only act on the folder its Editor has open:shared(requiresisolation = "none") runs the agent in place on the project the operator's warm Editor already has open — zero relaunches, full live MCP;per_worktree(requiresisolation = "worktree") gives each worktree its own managed Editor.- Readiness gate: before each unit, the plugin's
pre_ready_gatehook blocks until the Editor + MCP report ready (Unity:wait-for-readyfor IvanMurzak, connectivity check for CoplayDev); on timeout the unit is deferred with anATTENTIONnotice rather than starting a session against a half-open Editor. per_worktreelifecycle (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'sLibrarywith a reflink/CoW copy of the warm mainLibrary(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'sseed_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.
- 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/ TUIS) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as astoppedrun thatresumepicks up at the next item. - Every
state.jsonpublication is serialized by one advisory lock per run, keyed on the resolved run directory plus the logicalstate.jsonname and stored under the user state root rather than in git. Ignoring a final-componentstate.jsonsymlink 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 anOSErrorafter 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 recordsrepo_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.resolveand 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 thesession-synthesized-from-frontmattercatch and itsspec-marker-repairedrepair, #276, plus oneverify-command-resultper verifier command — emitted on the dev, repair and review legs alike, carryingverification_stageand a per-storyverification_sequencethat orders the passes across all three; the two passes that leave no record arebmad-loop confirm --reverify, which runs after the run is over, and any pass with no[verify] commandsconfigured, which records nothing because nothing ran — each entry also carryingspawn_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-timeOSError(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 theverify/directory below, and onepark-proof-of-work-skippedper attempt that cleared the dev artifact gate on anawaiting-operatorpark with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carryingzero_diff:truewhen 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),falsewhen it found changes, andnullwhen 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.falseis 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 onebundle-artifact-only-acceptedper sweep-bundle attempt that cleared the dev artifact gate on the artifact-only receipt (DW-273) — carryingstory_key,attempt,dw_idsandcount, the number of ignored files underimplementation_artifactsthis attempt created or changed against its start-of-attempt snapshot), and onebundle-artifact-baseline-unavailableper 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.jsonlrecords 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.jsonis the wait loop's proof-of-life, carryingtranscript_idle_s— seconds since the live transcript last changed,nulluntil a hook event names it (#680);resultless-stops.jsonlrecords 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'sverify-command-resultrecords — its own store, because every name inlogs/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 acapture_errorwhen 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,archiveandcleanremove the out-of-tree counterpart along with the run dir, andcleansweeps counterparts whose run dir is already gone; an archived run's tarball therefore no longer containsevents/— 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.jsonlrecordssession-endfor every session unconditionally — even a teardown that throws still lands one (statusabortedwhen the outcome is unknowable). A timed-out session's entry carriesfired_at(wall time the deadline was declared),teardown_s(wall seconds from that fire to this entry — the teardown gap), andexpired_clock(monotonic/wall/both—wallalone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carriestokens(raw) andtokens_weighted(cache reads atlimits.cache_read_weight), keeping per-session spend reconstructible; both arenullwhen the usage read failed, and both are absent on anabortedend.tokens_weightedis the end-of-session total — distinct from a tripped session'sbudget_weighted, the guard's mid-session sample at trip time.
- Coding-agent hooks (
Stop/SessionStart/SessionEnd/PreCompact) write structured event files the orchestrator watches; skills write a machine-readableresult.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 viaBMAD_LOOP_EVENTS_DIR; the state root itself resolves perBMAD_LOOP_STATE_DIR(see the env-var table in the README). The relay falls back to the legacy in-tree<run-dir>/eventswhen 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 tosession_timeout_min. Freshinitregistrations call the installedbmad-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_likerefusal on the write path (#493) as belt-and-braces rather than retiring it as redundant.
- 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.mdandmigrate-rewrite.mdtext 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 persistsPENDINGwith 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 leavessweep-migratenonterminal, 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. Oncemigrate-result.jsonis durable the task enterscommitting: 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 whatgit addof 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 symbolicHEAD(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.DONEis 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 duringtriage-verifyinstead 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_writereturnsNonefor the existing metadata-absence cases and nonregular targets. Nonabsence metadata faults and all text-readOSErrorfailures, including disappearance/type-change races after a successful probe, raiseLedgerReadFault(LedgerReadError)with the original exception chained as__cause__. Invalid UTF-8 still raisesLedgerReadErrorwith aUnicodeDecodeErrorcause. A refused authoritative read publishes nothing. Pre-lock presence probes, lock acquisition and writes retain rawOSError; 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, sodeferred-work.mdstays 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_excludestops the engine's own append from counting as the session's proof of work. Dedupe key is the fingerprintedorigin: spec-deferred <fp>plussource_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-reviewwith the follow-up review enabled, elsedone, 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 fourread_for_writesites (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 journalledger-read-degradednaming the site; the two reads that precede a publish — this harvest's append and the isolated unit's carry into the main ledger — normally journalledger-read-refused, raise anACTION REQUIREDnotice naming the ledger, and pause the run atescalation; a sweep's terminal post-merge harvest carry instead journalssweep-bundle-close-refusedand pauses atstory-gate, while its direct pre-terminal defer carry retains the engine route. Both routes leave the task's phase untouched, sobmad-loop resumeafter 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_escalatewould, demanding abmad-loop resolvesession and a clean rebuild over a fault that is not the story's. Bare, the first of those reads ended a story run asrun-crashwith 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): everydeferredworkmutator takes its own lockedread_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 aLedgerReadErrorraised from the mutator call itself — the harvest's seen-again mark and append, the commit-boundarycloses_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 adeferred-close-rollback-failedagainst bytes it cannot read. The notice names both kinds of write (findings to file, a declared close to record);bmad-loop resumere-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 configuredpre_commit_gateworkflow 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 byscm.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 originalOSErroras__cause__, so the locked-read catches cover them without catching lock/write failures. Pre-lock presence probes, lock acquisition and writes keep their rawOSErrorbehavior. 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 sameACTION REQUIREDnotice, 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 declaredcloses_deferred:then journalsdeferred-close-ledger-unavailableand 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 attendedbmad-buildstill append flat- source_spec:blocks directly, whichsweep --migratenormalizes. - Story-declared closure (
closes_deferred: [DW-5, DW-6], human-authored on astories.yamlentry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips tostatus: 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, andbmad-loop validatewarns about all of them before the run starts. (A non-listcloses_deferredinstories.yamlis 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 runbmad-loop sweep: the next sweep re-triages those IDs against the actual code and can close verified resolutions asalready_resolved, with the completed story's commit serving as evidence. - Hard gates (
gate: 3-2, 3-3on an entry): until the entry lands,bmad-loop validatefails (deferred.hard-gate) for every actionable story a token matches andrunpauses (story-gate) rather than dispatch one — a token gates a key it equals or prefixes at a key boundary (-, or a split-story suffix), so3-2covers3-2-invite-link, the stories-mode id3-2and both halves of a3-2a/3-2bsplit, but never3-20-later. Only an explicitstatus: doneretires 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 unmatchable3.2—./_are legal inside a sprint slug), an emptygate:line, agate:not lowercase at the start of a line, and prose declaringHARD GATE:(matched mid-line, sincereason:prose wraps — but not straight after a quote, so a citation stays silent) on an entry with nogate:line. Migration is checked rather than merely instructed: asweep --migraterewrite that drops agate: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 duplicateDW-<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 sweeptriages 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-3requires 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|criticalis 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 insweep.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-optionbundle_nameover 40 characters is truncated to 40 and journaled; malformed shapes and post-truncation collisions still fail through the existing retry path. Textbmad-loop statuson a sweep run prints asweep options:line givingmax_bundles,repeatandmax_cyclesat their effective values — the run'ssweep.jsonoverride, else itspolicy_snapshot(never live policy.toml) — labelledoverrideorpolicy, plus any selector; options that fail the resume loader's bound or digest checks readunverifiablewith the reason, and--jsonis unchanged (#815).- Bundles run the full pipeline (dev
--dw-bundle→ review → verify → commit); the review gate checks every bundle entry isstatus: done. - Artifact-only bundles (DW-273): a bundle whose only permitted deliverable lives under a gitignored
implementation_artifactsdir (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 noawaiting-operatorpark to fall back on. The bundle dev session may instead assert it by appending anArtifact only: trueline inside its## Auto Run Resultmarker, besideStatus:. Accepted spellings:Artifact only,artifact_only,Artifact-only— a run of at least one space, underscore or hyphen between the two words (the fusedArtifactonlyis no spelling and asserts nothing), case-insensitive, in the same bulleted or bolded label/value shapes asStatus:(**Artifact only:** **true**and- **Artifact only: true**both assert) — with the valuetruealone on the line (anchored to end of line:Artifact only: true for the ledger, false for codeis prose, not an assertion) and, likeStatus:, on the SAME line as its label (Artifact only:withtrueon 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 carriesartifact_only: trueonly 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 mintpark_asserted; frontmatter never mints it, and a loose"true"/1is 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 strictartifact_only: truemakes the gate rungit status --ignored --porcelain --untracked-files=allscoped to the configuredimplementation_artifactsdir and count only its IGNORED (!!) records — the tracked and untracked-not-ignored ones are what the ordinary probe already measured, so under thebmad-loop initdefault 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 (lstatmtime and size) every ignored entry under the dir at each genuinely new attempt's start (StoryTask.baseline_artifacts, stamped besidebaseline_commitand 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 tobundle-artifact-baseline-unavailableon 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 verbatimno changes in worktree since baseline commitreason, and a git fault at the gate escalates like the ordinary probe's. An accepted receipt is journaled asbundle-artifact-only-accepted(story_key,attempt,dw_ids,count— the number of owned entries). Underscm.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 itsartifact_deliverableslist 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-donecheck 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
intentfield; 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 bedoneand 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) andsweep-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.jsonand journaleddecision-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 nopost_decisionand is not counted closed. What the human is left with then differs by effect. Acloseleaves 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 storedcloseover a still-open entry and applies it, so a ledger that reads again repairs itself. Abuildstill 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 owndecision:audit line. The same row also fires with no raise involved (DW-186):record_decisionreturns 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 (nopost_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 abuildanswer 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_answerreturns it instead of discarding it, sobmad-loop decisionsreplaces 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 printingclosed now, and the TUI decision modal raises awarningtoast naming the id and does not count the answer intorecorded 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 abuild/keep-openanswer 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. Theclosehalf of that trade is no longer left to a later run either (DW-167). The interactive arm persists the answer and journalsdecision-answeredBEFORE it applies the effect — deliberately, so the human's answer survives a crash — which left a crash in that window with<run>/decisions.jsonclaimingeffect: "close"over an entry the ledger still listed as open, and every consumer read that answer as consumed: the reload accepts it,pendingfilters the id out so it is never re-asked, and no materialization lane matchesclose. 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 acloseis 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_effectthe prompt uses —sweep-decision-effect-reappliednames 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 seconddecision:line. The gate is a REPAIR/WRITE read because it gates a write: an absent or undecodable ledger re-applies nothing and writes onesweep-decision-effect-unavailableper candidate id rather than one row per file, so each row names an id to chase. Scoped to ids this cycle still asks about, sincepending'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 outsiderecord_decision's lock, and a snapshot cannot hold across the write it authorizes, so the walk alone passesrequire_openand 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 seconddecision: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. Thebuildhalf is narrowed rather than repaired (DW-200): abuildanswer whoserecord_decisionreported 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) raisesdeferredwork.LedgerWriteError— anOSErrorsubclass, sobmad-loop decisionsand 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 (WindowsLK_UNLCK,os.close),ledger_lockraisesLedgerLockReleaseErrorinstead of the bareOSErrorthat 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_confinedof<run>/decisions.jsonafter project pre-answers are adopted; DW-262) a directory planted at the store — silent at theS_ISREGprobe — or a refused or redirected parent (UnconfinedWriteError, the #593 confinement refusal) journalssweep-decisions-store-write-failed(file, thedw_idsadopted 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 anOSErrorthis cycle (DW-264): the SEEDED write-back is withheld (sweep-decisions-store-write-withheldnaming 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-withheldwithfile, the pendingdw_idsnot asked and the refusal'serror, plus an ATTENTION notice naming the file to repair): an answer taken there could not be persisted, has no second copy, and nothing reads abuildback off the ledger'sdecision: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 usesstat()rather thanis_file()so an OS refusal degrades uniformly across Python 3.11–3.14 tosweep-triage-reload-failedand a fresh triage, with an absent cache staying silent (DW-224); the cache WRITE-BACK after a validated fresh triage degrades tosweep-triage-cache-write-failed(unwritable: ...) and the plan is still returned and acted on (DW-247) — its own kind, sincesweep-triage-reload-failedis 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_closefinds no cache at the no-open exit and so recovers no stranded close, andbmad-loop decisionscannot 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 journalssweep-triage-cache-unlink-failedand 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 withUnconfinedWriteError(the #593 refusal) and lands on this samesweep-triage-cache-write-failedrow 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.jsongets the samestat()probe (DW-248): a metadata refusal journalssweep-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, whosereasonis 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 inerror, 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 todonewithout 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 noerror, 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
--repeatthere 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 forsweep-decision-effect-unavailablein 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 throughverify.commit_story/verify.finalize_commit, both of which open with a whole-treegit 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:_cycleruns none of that cycle's bundles, journalssweep-bundles-withheld(bundles_not_run,reason="ledger-unreadable") when there were any to withhold, and a repeating run ends on the existingledger-unreadablestop below with repair instructions. Non-repeat and--decisions-onlyruns retain their existing early returns, with no repeat-stop record or new ledger-repair ATTENTION notification. Those twogit add -Apathspecs 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_resolvedgets 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 existingsweep-resolved-close-unavailabledegrade 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 journalssweep-decision-ledger-refused(ledger, a fixedreasonofledger-unreadableorledger-inaccessible, the fault inerror) 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 tostate.jsonat 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 thesweep-bundles-withheldrow) 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'sgit add -Aswept 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-runsbmad-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). Astate.jsonwritten 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, journalsweep-ledger-commit-withheld(message,file, and a fixedreasonofledger-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 fifthrefuse_cause. In the other direction, a ledger-familysweep-ledger-commit-refusednow 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 fourrefuse_causetokens arm,target-absentincluded — an absent tracked ledger cannot crash the intent read, but a bundle dispatched over it commits with a whole-treegit 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-familysweep-ledger-commit-unavailablefrom the publisher's RESOLVE arm — aPath.resolvethat raisesOSError/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 (aGitErrorafter 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 (headlinethe 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 onsweep-nothing-open/no-openwith 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 addsdw_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:_loopmeets an inherited doubt ahead of_ensure_migrationand takes the doubt's ownledger-unreadablestop and repair notice before any rewrite session is spent. Accepted residual, unchanged: the gate does not extend toEngine._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
deferredworkmutator takes its own lockedread_for_writeahead of every write, so a ledger that turns undecodable or suffers an OS metadata/text-read fault (DW-279) inside that window raises from themark_done_many_reopenablecall 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 journalssweep-bundle-close-refused(siteone ofbundle-close-locked,bundle-reclose-locked,bundle-close-carry-locked;dw_idsthe ids the close was about to publish;reasonledger-unreadablefor undecodable bytes orledger-inaccessiblewhen the OS refused the metadata/text read — the same splitsweep-cycle-ledger-refusedandsweep-preanswer-prune-refusedmake), writes theACTION REQUIREDrepair notice steering at the UTF-8 or at the path's permissions/storage to match, with thebmad-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, besidesweep-bundle-close-carry-refused, not the engine'sledger-read-refused; theLedgerReadErrorcatch includes the OS-read subclassLedgerReadFault(DW-279), while pre-lock probes and lock/write faults retain their rawOSErrorbehavior. Terminal post-merge harvested carries share that sweep-owned row and story-gate lifecycle (DW-286):harvest-carrynames the pre-read andharvest-carry-append-lockedthe mutator read,dw_idsis 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-donerecords 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_nameis absent or discarded falls back todecision-<id>, which is a fallback and not a reserved namespace — a triage plan may legally author a bundle of that exact name, and_bundle_keywould 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, journaledsweep-bundle-name-dedupedwith 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'sstale-option(DW-123), and the two ledger-fact laneseffect-unlanded(DW-200) andentry-not-open(DW-214) — also quarantines the decision for the rest of the sweep run, so a later--repeatcycle 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_decisionsonstate.json, published immediately after the drop is announced), as is the unattendeddecision-skipped-unattendedlatch 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. Astate.jsonwritten 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>, ordw<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.jsonon 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'sdw_idsto 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 astate.jsonwritten 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…-9suffix (first free wins, journaledsweep-bundle-key-dedupedwith the original and resulting names) — applied to the NAME rather than the key alone, so the key, the<run>/bundles/<dirname>/directory, thebundle_name:line insideintent.mdand the key→name round-trip a degraded intent rebuild runs all stay consistent. Exhausting the bound journalssweep-bundle-key-collisionwith 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'sdw_idsbefore itsintent.mdis written (DW-144), so the intent document, the task and thebundle_closes_intendedthe 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 journaledsweep-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: itsbundle_closes_intended(which would otherwise close ids this task never ran), itsspec_fileandrestore_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 clearsdefer_reasonand advancesgenerationfor 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_redrivedeliberately 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_fileis written before the adopted ids are saved, so a crash between the two left a task reusing a still-presentintent.mdwhosedw_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 againsttask.dw_idsas 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), andsweep-intent-regeneratedcarries aregen_causesaying which ofmissing/dw-ids-mismatch/unreadableapplied — a closed slug rather than a free-textreason, sobmad-loop diagnoserenders the cause itself instead of a presence boolean. A document with nodw_ids:line at all cannot be graded and so regenerates too. An EMPTYtask.dw_idsis not an authority here — that is the pre-dw_idsstate.jsonshape, 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-269intent.mdis written throughatomic_write_text_confinedagainst 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/orbundles/<dirname>/) refuses withUnconfinedWriteError, 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 theSystemExitan 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-treegit 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-treegit add -Aand 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 andsweep-auto-finishedis journaled, where the same fault previously raised through the child and reached the parent'ssweep-auto-failedarm (afailedentry insweeps_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 freshbmad-loop sweepbecause the finished child cannot be resumed — but it means the PARENT's journal no longer announces a ledger fault the child already announced, andbmad-loop diagnoseon 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 --jsonandbmad-loop diagnose, namingbmad-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, hitsmax_cycles, or could not read the ledger — either because the pre-answer prune refused it (sweep-repeat-donereason="ledger-unreadable"on undecodable bytes — DW-182/186 — orreason="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 journalsweep-cycle-ledger-refusedwith the same two tokens and the fault inerror, then stop on that token countingcycle - 1, since that cycle did no work — thelegacy-appearedconvention). 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, andno-selected— the repeat exit when the--only/--min-severityselector selects nothing on a cycle after the first) also writes its token asstop_causebesidereason(DW-201) — a closed slug rather than the free-textreason, whichbmad-loop diagnoserenders as a presence boolean, so a dump named no stop at all.reasonis unchanged and still written. Theno-progressandmax-cyclesstops, 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, sinceimplementation_artifactsis configurable to any absolute path and the ledger may be symlinked out of the project. Re-runningbmad-loop sweepafter the repair is the resume, but changes in the configured code repository (repo_root) have to be committed or stashed first:sweepchecks 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 outrankno-progressandmax_cycles(andledger-inaccessibleoutranksledger-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 onno-open.deferredwork.read_for_writewraps OS metadata/text-read faults asLedgerReadFault(LedgerReadError)with the originalOSErrorchained (DW-279); the sweep handles that subclass before the decode parent to preserveledger-inaccessible, the original OS attribution, and the permissions/storage repair notice. Itsstat+S_ISREGprobe (DW-221) preserves this refusal on Python 3.14, whereis_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 otherread_for_writecalls insweep.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_bundlecalls_write_intentbare, so a rival write that retires an entry between the keep-loop screen's read and the intent read raisesMissingLedgerEntriesErrorand 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_bundlesre-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 journalssweep-intent-ledger-refused(naming the bundle, the ledger, the same two tokens and the fault inerror), notifies with the ledger path, a repair-and-COMMIT steer and thebmad-loop resume <run_id>route, clears the task's baseline pair and raises the story-gate pause on that task — nosweep-repeat-done, no new stop token, no new pause stage. A stop could not keep the re-drive promise:_loop's return is persistedfinished, whichresumerefuses; the pause leaves the run resumable, andruns.unreadable_sweep_ledgerfronts 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-tokensweep-repeat-donestop — a cycle whose decision phase left the ledger in doubt withholds its bundles and ends here, on the sameledger-unreadabletoken, 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 reportsledger-inaccessiblethrough 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 DATEto bound by close date;--dry-runpreviews): moves closed (status: done <ISO date>) entries to siblingdeferred-work-archive.md(body preserved, anarchived: <date>marker appended), leaving an id-preserving stub (status: done <date>+archived: <date>) so grep andcloses_deferredcross-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), liveseverity:/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 itsarchived:stamp toarchived-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 tobmad-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 toin-reviewand 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 secondbmad-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 sameDW-<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 withgit 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 asweep --archiveover 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_publisheddeliberately keeps no missing-ledger answer — an absent ledger there means create, which is a write. The rollback paths that span agit reset --hardcannot 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 journaledledger-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-divergedmerges by appending the entries disk has since lost,ledger-restore-skipped-divergedskips, andsweep-migration-restore-divergedescalates for a human to re-run the sweep. And a failed commit reopens exactly the entries the story itself closed, journalingdeferred-close-rolled-backwith their ids anddeferred-close-reopen-unmatchedfor an entry whose undo marker a foreign edit has displaced.
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.
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 decisionand 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.
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,closeorkeep-open.closeis 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. keyandlabelmust be strings when present, for every effect.intentandbundle_namemust be strings when present forbuildonly. 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,
resolutionandanswered_atare not trusted answer inputs. A resumed close takes its resolution from the agreeing current option, not from an answer'sresolutionfield. 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.
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.
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'sValueErrorfor a non-encodable path are treated as absence. - Other OS faults, including
EACCES,EIO,ESTALE,EBADFandELOOP, are unreadable faults. Explicitstat/lstatprobes expose these rather than relying on interpreter-dependent boolean path predicates. - Resolution runs first. Both publishers catch
OSError,RuntimeErrorandValueError(including encoding-error subclasses). The sweep journalssweep-ledger-commit-unavailable; a ledger operand also arms persisted ledger doubt. The out-of-band publisher reportstarget-unreadablewith 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.
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.
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.
- Opt-in second story source (
[stories] source = "stories"+spec_folder, orbmad-loop run --spec <folder>): drives the same loop off a typedstories.yaml(abmad-specStory Breakdown, sibling ofSPEC.md) instead ofsprint-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>.mdand 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);doneskipped, non-terminal statuses resumed on re-dispatch,blocked/sentinel/ambiguous stops the run for resolve.bmad-loop run --dry-run --spec <folder>andbmad-loop statusprint 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.
- 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-epicis inert in stories mode — the flatstories.yamllist has no epics, so the boundary never fires; use the per-story checkpoints (below) orper-story-spec-approvalthere. - Per-story checkpoints (stories mode): independent
spec_checkpoint(pause before code to review the plan; approve → implement, or request a replan) anddone_checkpoint(pause after the story commits; skipped when it is the last story). Additive togates.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
pviewer 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.
- 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-Windowspsmuxbackend 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 viaregister_adapterand arrives through thebmad_loop.adaptersentry-point group, with its selecting profile throughbmad_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] backendviabmad-loop mux set→ platform default → first available platform match), the process-lifecycleProcessHost(register_process_host—terminate/force_kill/is_alive/identity), and the hook interpreter (ProcessHost.hook_interpreter());bmad-loop validateruns 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 — thecopilotbinary, not the VS Code extension;agentStopturn-end,-iinteractive 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, profileopencode-http, aliasopencode) — one headlessopencode serveper session, SSEsession.idlecompletion 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 inlogs/<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 setmodelasprovider/model; the Unity plugin's window guards don't apply (there is no window). - Supported, E2E-verified (no token usage yet):
cursor(Cursor CLIcursor-agent, verified against 2026.08.04 and 2026.09.02) — interactive launch,stopturn-end hook andsessionStartin a project.cursor/hooks.json(versioned file, bare{"command": …}entries; Cursor 3.x loads no hooks from a project file with no top-levelversion), 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--forcealone does not clear and that seeding Cursor's own.workspace-trustedmarker does not satisfy either. Since trust is granted per launch rather than per stored path,isolation = "worktree"works (unlike antigravity).[adapter] extra_argsreplaces the bypass flags, so it must keep--trust.usage_parser = "none"pending a transcript-schema probe. Verify withprobe-adapter cursor. - Experimental,
isolation = "none"only:antigravity(Google'sagy≥ 1.1.3) —-iinteractive launch,Stopturn-end hook (flat handler in.agents/hooks.json, no SessionStart/SessionEnd),--dangerously-skip-permissionsfor unattended runs;usage_parser = "none"permanently — agy's transcript exposes no usage data (tokens live only in an internal SQLite/protobuf store).agygates each workspace on an exact-pathtrustedWorkspacesentry and blocks on an interactive trust dialog, which--dangerously-skip-permissionsdoes not bypass — so worktree isolation hangs (#169). Verify against youragybuild withprobe-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
effortstring on[adapter]and every[adapter.<stage>]table (values are provider/model-specific —high,max, … — so nothing is validated against a catalog), inherited exactly likemodel(a stage that switches client falls back to""= provider default). Only theopencode-httpadapter carries it: it is sent as the per-callvarianton everyprompt_asyncbody the session issues (initial prompt and every nudge) and omitted entirely when empty, never throughOPENCODE_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 validatereportspolicy.effort-unsupported(a warning, exit code unchanged) when a stage on that family sets it. The value never reaches argv, soconfig_digestis unaffected;run --dry-runshows it aseffort=<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'sadapterfield names a kind resolved against the registry, which a co-installed package extends. bmad-loop probe-adaptercollects + 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--probefor 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.
- 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, persession_budget_mode—warn(default) raises an ATTENTION + lifecycle breadcrumb only;enforcealso sends a wrap-up nudge, grantssession_budget_grace_s(default 240s) to finish, then terminates the sessionover_budget(ordinary retry→defer routing; an artifact flushed at kill time is still honored). Sampling is live-verified onclaudeand 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 atcache_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 atoken-budget-exceededjournal entry carryingweighted,totalandbudget; 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 andbmad-loop statuseach lead with<weighted> weightedand name the raw count as(<raw> raw incl. cache reads), the TUI pairs weighted and raw columns, andsession-endentries carrytokensbesidetokens_weighted. - Token usage read from each CLI's local session transcript (per-profile
usage_parser), aggregated per story (bmad-loop status).
- 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_NOFOLLOWopens withdir_fdon POSIX,NtCreateFilerelative 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, andmkdir(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. Apolicy.tomlan operator marked read-only is refused with aPermissionErrorinstead of being replaced and left still reading0444. 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, hooksettings.json— butsprint-status.yamldeliberately 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 typedstories.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_ratefor slow/SSH links; persisted dashboard pane sizes),[operator](whether a dev session may park a story atawaiting-operator),[mux](machine-scoped multiplexer backend choice). [dev] skillnames 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 tobmad-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. asetsidbackground 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.
- 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+wkeyboard resize mode; sizes persist per-project to[tui]inpolicy.tomland re-apply on the next launch. - Survives TUI exit/crash: runs launched from the TUI are detached
bmad-loopprocesses in a dedicatedbmad-loop-ctltmux 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+etoggles all), validated with the engine's own parser, unset keys show defaults as placeholders.
- Each run drives agents in a dedicated
bmad-loop-<run-id>session;attachto watch live. - Auto-teardown on finish (
cleanup_session_on_finish, disable to inspect); a hardstopalways kills it, a gracefulstop --gracefultears it down under the samecleanup_session_on_finishgate a normal finish uses; paused/interrupted runs keep the session forresume. bmad-loop cleanup(orcin 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.--jsonemits 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 adry_runflag) instead of the text.ctl_windowsis a three-way partition —removed(verified gone after the kill; under--dry-runit is the would-close plan),survived(still listed) andunverifiable(the liveness listing itself failed) — because killing a window is best-effort and reports nothing; a survivor is retried by the nextcleanup, 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 andctl_windows.scan_errorcarries the reason (text mode puts it on stderr), so a reported preflight failure is never document-shaped like "nothing to prune". (scan_errorcarries 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.removedkeeps its older, weaker meaning: an attempted kill. Plan and outcome share one schema — same fields, same meanings, withdry_runsaying 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, becomessessions.unverifiable_pidin the document, leaving stderr empty.
bmad-loop cleanreclaims disk (distinct fromcleanup, which is only tmux). It tears down git worktrees a mid-flight stop left mounted — the main accumulation source: each carries a real UnityLibrary/(incl. the MCP-server build), whichgit worktree removecannot reach once the engine was killed before teardown. It then trims the heavyworktrees/tree from runs kept for history (the run still lists in the dashboard — discovery readsstate.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/cleanremove the run's control-plane dir under the state root too, andcleanadditionally 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 itsstate.jsonparsing, 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'sconfig-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.jsoncarries a second copy that travels with the run directory, andresumefalls 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-runpreviews,--retain N/--hardtune 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.cleanrecords 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. --jsonemits a stable machine-readable document per the contract below (schema-versioned; the effective retention policy,freed_bytesas a raw integer, and the paths and run ids underworktrees/trimmed/archived/deleted/protected, andstate_dirs_sweptas a count) instead of the text. Plan and outcome share one schema, withdry_runsaying 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 becomesunverifiable_pidin the document, leaving stderr empty.- Prevention is automatic: every
run/sweepstart reconciles worktrees leaked by a prior finished run ([cleanup] auto_clean_on_finish), and the Unity plugin'spost_runhook removes the IvanMurzak MCP server's downloaded/tmp/<company>/<product>/*.zipand truncates its unbounded editor log ([cleanup] clean_tmp). For recurring housekeeping of stopped runs, schedulebmad-loop clean.
bmad-loop initinstalls the threebmad-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 validatepreflights every prerequisite: BMAD config, plugin manifests (plugins.manifests: every bundled and project-localplugin.toml— project-local read from the configuredrepo_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 unsupportedapi_versionis a warning, since a run skips it — #765), sprint-status, git (including its version — a host below the 2.34 support floor gets agit.versionproblem 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 onPATHbut fails--version, typically a dead WSL/npm shim, adds anadapter.binary-unrunnablefinding at warning severity —adapter.binaryitself 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 yourPATH, 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 itscustomize.tomlreview layers (or fromstep-04-review.mdon releases that name reviewers inline), so both the mergedbmad-reviewtopology and the standalone-hunter one validate, and configured layers naming an uninstalled skill are caught — plus itscustomize.toml.- Where the artifact paths come from (#769, #154):
implementation_artifacts,planning_artifacts,output_folderandrepo_rootare 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 abmad-configfailure, never a silent fallback to the YAML. The two artifact dirs are required;output_folderdefaults to{project-root}/_bmad-outputandrepo_rootto 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 aBMAD_LOOP_MUX_BACKEND/[mux] backendchoice forced it. Afallbackselection is reported as a warning (its own label says no available backend matches this platform); a selection that outright failed is carried bymux.preflight, and a detection that failed bymux.backends-detectedat warning — so a missingmux.selectionline is normally explained by another finding (the historical unregistered-tmux fallback is the one silent exception; see the--jsoncontract note indocuments.py). On top of that,host.win32-on-wsl-pathwarns 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 awin32interpreter) and neither flips validate's exit code.bmad-loop diagnosecarries the same two facts in its Environment block assys.platformandwin32 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
donespec. Your standard BMAD install is never modified.
bmad-loop init— install skills, hooks, policy, gitignore.bmad-loop validate— preflight all prerequisites.--jsoninstead emits a stable machine-readable document (schema-versioned; theokverdict, the queuemode/spec_folder, per-severitycounts, and every check as a flat emission-ordered finding with a stablecheckid,severity, humanmessageand structureddetail) 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 awarning: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 plainpsmux lsshows none of them and says so rather than erroring (#537);mux set <name>persists a machine-scoped choice into policy.toml (--clearreverts to auto,--forceallows a name only registered on the target machine). Bundled backend:tmux; external backends (e.g. the herdr adapter) register via thebmad_loop.mux_backendsentry-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 tomux. Unlikemuxthere is no global choice to persist: a kind is selected per profile by itsadapterfield. A profile referencing an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get awarning:on stderr;validatereports the same asadapter.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 reportsno such runbefore 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 freshbmad-loop sweepin 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: afinishedrun still getsalready finished, and a run whose id aliases the control session's own keeps that refusal and itsbmad-loop deleteroute. (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 andno such runrefusals, 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; thebmad-loop sweeproute it pairs with is the one the sweep run's ownledger-inaccessiblestop gives; every other clause (the path, thebmad-loop sweeproute, commit-or-stash in a clean worktree, stays resumable) is shared.deferredwork.read_for_writewraps OS faults asLedgerReadFault(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 directread_for_writesites — 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 resolveand 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 (--listto just show them).--jsoninstead 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 derivedrecommendedflag) 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 atawaiting-operatoronce you have carried out the external actions it owes: acknowledges each in turn (--yesskips the prompts), writes the spec's## Operator Confirmationaudit section, advances spec and board todone, and commits the pair.--listshows every parked story and what it owes;--reverifyre-runs your[verify]commands first and blocks on failure; re-running it on an interrupted confirmation finishes that confirmation.--jsonemits a stable machine-readable document per the contract below — per parked story the key, actions, spec file, spec/board status, the parking run and thecommitcarrying the park (empty until the record is in a commit), plus derivedconfirmable/resumableflags, theconfirmation_recordedreading behind the latter, and a humandriftreason; 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.--jsoninstead 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.--jsoninstead emits a stable machine-readable document (schema-versioned; run state, snapshotcache_read_weight, per-story phase/attempt/review-cycle/tokens/commit/defer-reason, plus the additively-added run-leveladapters— the dev/review/triage adapter the policy snapshot resolves to,nullon a run predating adapter stamping — and per-storyadapters_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).--jsonemits 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.--gracefulinstead 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 resumablestoppedrun, suppressing any pending auto-sweeps;--cancel-gracefulwithdraws a pending request. Both modes ride the samestop-request.jsoncontrol file, which carries the mode: a hard stop lodgesmode: "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 outRESULT_GRACE_Sfor 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 ofstopped, and the external force-kill +run-stop fallback=Truepast 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:xhard-stops,Srequests a graceful stop.bmad-loop delete <run-id>— delete a run directory and its out-of-tree control-plane dir (--forcestops 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/archiveand remove it, control-plane dir included (--forcestops 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 noevents/. 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 isfsynced before the publish — the run dir is removed immediately after, so it is the only remaining copy. A published archive lands at mode0600rather 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
cleanupcan read, so removing it would leak the session (#419). A session tagged to another project carries its own proof and never blocks. Runcleanupfirst, having confirmed the session is this project's (attach): for an untagged sessioncleanupproves 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.cleanleaves 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 — ahas-sessionwhose 500 ms connect does not land reaps a live session's registry entry, and until the server's registry maintenance re-writes itlsomits 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 withkill-sessiononbmad-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 exportbmad-loop muxprints. Confirm the session is this project's before killing, since the name carries no project — and note thatattachis 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.--jsonemits 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 heavyworktrees/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.--jsonemits what was reclaimed (or would be) as one stable machine-readable document per the contract below, withfreed_bytesa raw integer.bmad-loop tui— the interactive dashboard (--low-frame-ratefor 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--probelive 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.
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.