v0.9.0
Added
-
Active agent indicator (#153). The TUI run header and tasks table now name who is driving —
the resolved adapter·model for the live stage, or the run's configured adapters when no session is
open. Session-start journal entries and session records stamp the resolved adapter identity, and
bmad-loop status --jsongains an additive run-leveladapters(the snapshot-resolved
dev/review/triage identity,nullon a run predating stamping) and per-storyadapters_used(the
identity actually recorded per role) — both are a projection of the run's persisted policy snapshot
(re-stamped from current config on resume) and its recorded sessions, not live policy read at status
time; a run whose snapshot predates adapter stamping reportsadapters: nullrather than a
fabricated default. Schema version unchanged. -
Graceful stop (
stop --graceful, TUIS). Ask a live run to finish its in-flight item —
a story or sweep bundle through commit, or an in-progress sweep triage — then finalize cleanly
and stop as a resumablestoppedrun, instead of the hard SIGTERM stop that kills mid-item.
Delivered through astop-request.jsoncontrol file consumed at the next item boundary, so it
needs no signal and works on every platform and multiplexer backend. Pending auto-sweeps are
suppressed;--cancel-gracefulwithdraws a request, and a hard stop still wins over a pending one.
status --jsongains an additivegraceful_stop_pendingfield (schema version unchanged). -
bmad-loop validate --json(#205). A stable, schema-versioned JSON document of the
preflight: theokverdict, the queuemode/spec_folder, per-severitycounts, and every
check as a flat emission-ordered finding. Each finding carries a stablecheckid —
hooks.registered,adapter.binary,skills.base-incomplete, … — so CI can branch on a
particular failing gate instead of matching remediation prose, which is the part most likely
to be reworded.detailkeeps what each check knew before it flattened itself into a
sentence:mux.backends-detectedkeeps every detected backend row rather than the text's
tmux*, psmux (unavailable)soup, whose trailing*a consumer had to parse to learn which
backend was selected, andskills.base-incompletekeepsmissing_markersas a list rather
than a", "-joined string. A failing check emits the whole document and still exits 1 —
the nonzero code is the verdict being reported, not a failure to produce one, so the existing
bmad-loop validate || exit 1in CI is untouched.machine.pygains the clause that makes
this well-defined: parse non-empty stdout whatever the exit code, and read the verdict from
the document's ownok, which unlikercseparates "the checks failed" from "the command
broke". The text output is byte-for-byte unchanged. -
bmad-loop clean --json/bmad-loop cleanup --json(#204). Stable, schema-versioned
JSON documents (one per command — they are separate contracts) reporting what a reclaim
removed, or under--dry-runwould remove: forcleanthe worktree paths, trimmed,
archived, deleted and protected run ids, the effective retention policy, andfreed_bytes
as a raw integer — the text's~1.2MBis a rendering of that number, and formatting is the
renderer's job; forcleanupthe run ids whose sessions went, the live ids left alone, and
the ctl windows closed. Plan and outcome share one schema — same fields, same meanings, with
dry_runsaying which one you are holding — so a script can pre-flight and then compare
against what actually happened. The values are each invocation's own sample rather than a
promise the two agree:freed_bytesis re-measured, and the world can move between the
preview and the commit. The real paths are now scriptable at all — they previously discarded
the per-item data and printed only a summary line, andprotectedwas a bare count. Both
commands printed progress as they mutated, and both warned mid-loop about an unverifiable
engine pid; under--jsonthat warning becomes a document field, so stderr stays empty and
stdout stays one pure document. -
bmad-loop decisions --json(#203). A stable, schema-versioned JSON document of the
pending deferred-work decisions, so a script can select an option by policy and pre-answer
it rather than scraping the numbered text. It is strictly richer than that text, which drops
each decision'scontextand shows only key/label/effect per option — hiding theintent,
resolutionandbundle_namethat decide what the next sweep actually builds or writes.
The recommendation, a(recommended)suffix on a free-text line in the text form, becomes a
derived boolean on the option it names.--jsonimplies the listing and never prompts (the
interactive prompter reads stdin and cannot coexist with a pure document), and nothing
pending is a valid empty document with exit 0. -
bmad-loop list --json(#192). A stable, schema-versioned JSON document — one entry
per run, oldest first (short ref, run id, type, started-at, liveness-aware status, paused
stage) — replaces the text table when passed. Unparseable runs are included as status
unknown, and an empty runs dir yields a valid empty document with exit 0. -
bmad-loop status --json(#190). A stable, schema-versioned JSON document (run
id/type/source, derived status + pause fields, snapshotcache_read_weight, raw +
weighted token totals, and per-story phase/attempt/review-cycle/tokens/commit/defer
reason) replaces the text output when passed. This is the supported machine-readable
surface — the human text, whose layout #129 already changed once with no warning
path, is now explicitly best-effort. -
session-endjournal entries carrytokens_weightedbesidetokens(#129). Only the
raw scalar was persisted, and the weight cannot be backed out of it, so a session's
cost-weighted spend was unreconstructible after the fact — the weighted figure existed
only for sessions that tripped the budget guard. Every entry whose usage was read now
records both.null(never0) when the usage read failed, since untracked is not free;
both fields stay absent on anabortedend, where no read happened. Distinct from a
tripped session'sbudget_weighted, which is the guard's mid-session sample at trip time
rather than the end-of-session total. Thecache_read_weightknob also gained a
description in the TUI settings screen, where it had none. -
Mid-session token-budget guard (#158). Both adapter wait loops now sample cumulative
weighted usage every ~30s and act on crossing the new per-session cap
(limits.max_tokens_per_session, default 4M weighted) perlimits.session_budget_mode:
warn= one ATTENTION + lifecycle breadcrumb;enforce= wrap-up nudge +
limits.session_budget_grace_s(default 240s) to finish, then termination with the new
over_budgetsession status, which rides the ordinary retry→defer routing. Defaults to
warn: on upgrade, existing installs gain visibility (one ATTENTION line per over-cap
session) but no terminations — setsession_budget_mode = "enforce"to opt into the
hard bound, or"off"to silence the guard entirely. Session-end
journal entries carrybudget_weighted/budget/budget_modefor tripped sessions.
Live-verified onclaude; other transcript-reading profiles sample best-effort, and
adapters with no mid-session usage signal (usage_parser = "none", Copilot) stay inert. -
OpenCode adapter (
opencode-httpprofile, aliasopencode). Drives
OpenCode ≥ 1.18 entirely over HTTP/SSE — one headless
opencode serveper session (no tmux window), SSEsession.idleas the completion
signal with an HTTP poll fallback, per-session server password, hermetic skills, and
token usage read back over the API. Full dev/review synthesis parity via the new
_ResultFileMixin/_DevSynthesisMixinseams ingeneric.py; profiles gained a
hookless[hooks] dialect = "none"mode (no hook registration anywhere). Install the
HTTP client withpip install 'bmad-loop[opencode]'; setmodelasprovider/model.
The pinned 1.18.2 API contract is recorded in the adapter's module docstring, guarded
by a zero-token real-binary smoke test (tests/test_opencode_live.py, skipped when
the binary is absent). -
Native-Windows
psmuxmultiplexer backend (experimental). A bundled builtin that
drives runs on native Windows through psmux — a ConPTY tmux re-implementation that speaks
the tmux CLI via its ownpsmuxbinary — so tmux's session/window model and the
bmad-loop-<run-id>/bmad-loop-ctlsession names carry over unchanged. It registers for
win32and is the platform default there, selected automatically when thepsmuxand
pwshbinaries are onPATHand psmux reports newer than 3.3.6 (older releases can
force-kill a recycled PID during teardown, so they read as unavailable and selection falls
through). Native Windows stays experimental — window hosting, attach/detach mapping, and
Unity cache-path correctness are tracked in the roadmap — but the dev→review→verify→commit
loop and TUI observation run. WSL is unaffected (it is Linux and uses tmux). (#58) -
Out-of-tree multiplexer backends (
bmad_loop.mux_backendsentry points). A backend
package installed next to bmad-loop (e.g.uv tool install bmad-loop --with <adapter>) now
registers itself with no config step: before every selection, core imports each module
advertised under thebmad_loop.mux_backendsentry-point group, whose import-time
register_multiplexer(...)call makes the backend selectable exactly like a bundled one
(builtins load first, so default selection is unchanged by installing an adapter). A package
that fails to import can never break selection — the failure is recorded and surfaced as a
warning:line bybmad-loop muxand a note in thevalidatepreflight
(external_backend_errors()). -
Unity modal-dialog guards (
[plugins.unity]). A chronically-dirty Unity scene raises modal
Editor dialogs ("scene changed on disk", "save changes before closing") that freeze the MCP
dispatch loop and stall the whole run. The bundled Unity plugin now defends in depth: it seeds an
editor-onlySceneAutoSaveGuardinto the project (install_scene_guard, default on), quiesces
the Editor around a failed-attempt rollback sogit reset --hardcan't leave a stale scene open
(quiesce_on_rollback, default on), and appends the scene-save discipline (from a shipped
unity_facts.md) to every dev/review prompt so the agent saves at the boundaries that would
otherwise trip a modal. As a last-resort observability net, an opt-in detect-only probe
(dialog_probe, default off) watches — via xdotool, X11/Linux only — for those dialogs and
reports any it sees (a JSONL record, anATTENTIONline, and a best-effortnotify-send); it
never clicks or keys anything, no-ops where there is no X display, and self-reaps when the engine
exits (dialog_probe_interval_sec,dialog_probe_notify). -
Follow-up-review damping (
limits.max_followup_reviews, default 1). Bounds how many extra
review rounds a story is granted solely because a completed round finalizedstatus: doneyet
still setfollowup_review_recommended: true. Once spent, the next such round force-converges —
verify, then re-file the lingering recommendation to the deferred-work ledger, then commit —
instead of burning cycles up tomax_review_cycles. This damps the structurally non-convergent
case where every review pass patches findings and therefore recommends another pass. The damped
converge is the expected steady state and stays quiet (no ATTENTION); only the re-review cap (a
story that itself originated from areview-budget-followupentry and still won't converge) still
notifies. Verify-repair rounds, non-terminal rounds, andPAUSE/DEFER/RETRYnever spend the
grant;0never honors a pass's own recommendation.runs.rearm_escalationresets the counter so
a human-resolved re-drive gets a fresh budget. -
Resizable dashboard panes. Every pane boundary is now adjustable by mouse-drag (divider
bars, which also carry the Sprint / Deferred Work headings) or a keyboard resize mode (ctrl+w,
then←/→for the sidebar,↑/↓for the active horizontal split,Tabto pick it,Escto
exit). Sizes persist per-project to a new[tui]section inpolicy.tomland re-apply on the
next launch; untouched projects keep the previous fixed proportions. -
Dev-choosable multiplexer backend selection (#87).
get_multiplexer()now resolves by
precedence —BMAD_LOOP_MUX_BACKENDenv var → the new machine-scoped[mux] backendkey in
policy.toml → the platform default (win32:psmux, elsewhere:tmux) when installed → the first
registered backend that matches the platform and isavailable()— so two same-platform
backends (psmux / tmux-windows) no longer collide by registration order. Forced names are trusted
(no availability gate) and fail loudly when unregistered, naming the policy file. New
bmad-loop muxlists registered backends (platform / available / version / selected + why);
bmad-loop mux set <name>persists the choice (--clearreverts to auto,--forceallows a
name that only registers on the target machine); no interactive prompts anywhere.validate's
preflight lists all detected backends when more than one is registered and notes an env/policy
forced selection. A tmux-less POSIX host still selectsTmuxMultiplexerand reports it
unavailable, exactly as before. -
Seam-canonical window targets. The
=session[:window]target grammar is now owned by the
TerminalMultiplexerseam instead of living as hand-assembled tmux syntax in core: a new
concretetarget(session, window=None)encoder (overridable per backend, tmux inherits the
default and passes it straight through) and a module-levelparse_target()decoder that
native-id backends reuse instead of re-deriving the grammar (the out-of-tree herdr
adapter's_parse_targetdelegates to it).runs.py/tui/launch.py/tui/app.pyformat every
target viatarget()(newruns.session_target/launch.ctl_targethelpers) — output is
byte-identical, so no backend or operator behavior changes; the contract is documented in the
adapter authoring guide's new "Window targets" section. -
Herdr multiplexer backend — shipped out-of-tree. A complete non-tmux-family
TerminalMultiplexerbackend for herdr's cross-platform
workspace/tab/pane model was developed in-tree (engine run path #136, TUI-launch surface
#137) and extracted before ever shipping in a release to
bmad-loop-adapter-herdr, where it
co-installs with bmad-loop and registers through thebmad_loop.mux_backendsentry-point
discovery above. Core bundles only tmux; herdr's capabilities, remaining degradations, and
operator notes live in the adapter repo's docs. -
Stories mode — a second planning pipeline that drives the loop off a typed
stories.yaml(folder+id dispatch) instead ofsprint-status.yaml. Opt in with[stories] source = "stories"+spec_folder, or per run withbmad-loop run --spec <folder>(overrides policy);--storythen filters by story id. Each entry dispatches by folder + id — the dev skill creates-or-resumes the story spec at<folder>/stories/<id>-<slug>.mdand the orchestrator reads that id-keyed path back deterministically (no shared board to line-edit, no result-artifact mtime-scan). Strictly linear schedule (list order, nodepends_on);bmad-loop run --dry-run --spec <folder>andbmad-loop statusprint the board (id · live disk state · checkpoint markers · title). Sprint mode is unchanged and remains the default. Requires abmad-dev-autonew enough for folder+id dispatch — the run preflight checks and remediates. -
Per-story human checkpoints (stories mode). Independent
spec_checkpoint(pause before code to review the plan — dev halts atready-for-dev; approve to implement, or request a replan that resets the spec todraft) anddone_checkpoint(pause after the story commits, skipped when it is the last story); both additive togates.mode. A blocked story escalates + resolves as in sprint mode, with a pre-planning-halt sentinel auto-deleted (a copy preserved under the run dir) on re-arm. -
TUI human-in-the-loop surface for stories mode. The sprint tree is replaced by a stories board (id · live disk state · spec/done checkpoint markers · title) when a stories-mode run is selected; paused runs carry a per-run pause-kind badge and the run list shows a global ⚑ N need attention count;
popens the stage-appropriate viewer — plan-checkpoint spec review (Approve & resume / Request replan), story-checkpoint summary card (Continue / Stop), escalation with story context (Resolve / Re-arm & resume), and a gate spec viewer that the existing spec-approval/epic pauses reuse. The start-run modal gains a source select + spec-folder field with a live schedule preview. Every TUI action calls the same code paths as the CLI. -
Intent-gap patch-restore recovery. When review halts on an
intent gap,bmad-dev-auto
now saves the attempted change as a patch before reverting (BMAD-METHOD#2564). If the attempted
reading was correct,bmad-loop resolvere-arms the spec toin-reviewand re-applies the patch
onto baseline after every reset, so the re-driven session resumes review on the restored diff
instead of re-implementing. New--restore-patch <path>flag for the--no-interactivepath; a
patch that fails to apply escalates instead of running on a half-restored tree (a resolve session
that committed over the patched lines triggers exactly this — re-resolve without a restore).
Restore is rejected up front for worktree-isolation runs and for stories-mode pre-planning
sentinels, and the latched patch file itself never counts as proof-of-work. Deferred-work
sweepbundles get the same recovery — an escalated bundle re-arms toin-reviewand the
re-driven bundle session resumes review on the re-applied patch (#75). -
Preflight covers the inline review layers.
bmad-loop validate(and run-start) now require the
three upstream review-hunter skillsbmad-dev-auto's step-04 invokes —bmad-review-adversarial-general,
bmad-review-edge-case-hunter, andbmad-review-verification-gap(new in BMAD-METHOD#2550) — plus a
customize.tomlinbmad-dev-auto(its review-layer config, BMAD-METHOD#2535/#2550). A pre-July bmm
install missing any is reported with remediation before a run stalls.
Changed
-
The TUI's validate modal (
v) rendersvalidate --jsoninstead of the text output (#210).
One row per check — glyph, stablecheckid, message — with the verdict taken from the
document'sokrather than the exit code, which cannot tell "the checks failed" from "the
command broke". A check'sdetailis now reachable at all: inline for warnings and problems,
anddtoggles it on for everything, somux.backends-detectedexpands to a row per backend
instead of the text'stmux*, psmux (unavailable). A failure adds a footer noting the gates
are chained — the later gates emit nothing after one fails, so a short list is not a short
list of problems. An unrenderable document (a newer schema, unparseable stdout) re-runs
validate in text mode and shows the old modal unchanged. -
validatereports a failed external mux backend as a warning, not a note (#210). The
mux.external-backendfinding has always read as a failure — "external mux backend 'x'
failed to load: …" — while carrying severityok, so it counted as a passing check; it is
nowwarning, matching whatbmad-loop muxhas always printed for the same condition. It
stays belowproblemdeliberately: selection degrades past a broken external, so the verdict
and exit code are unchanged. On an affected hostvalidate --json'scountsshift by one
(warning+1,ok−1) whileokand rc do not; the schema version is deliberately
unchanged, since the document contracts eachcheckid, not a given check's outcome. The
text line gains the doubledok: warning:prefix thatrender()preserves by design. -
BREAKING:
probe-adapternow runsdiagnose's egress leak self-check, and captured hook
payloads ship as a schema instead of scrubbed values (#199). The rendered report re-scans
itself before emitting (the guard moved tosanitize.guard, one audited implementation for
both commands): an email / secret / home path / username in the final bytes makes the command
refuse to emit — message on stderr, empty stdout, exit ≠ 0, no--outfile — and a stray
occurrence of the pseudonymized project directory name is repaired to its alias and disclosed.
Each captured event now reports dotted key paths with leaf types (tool_input.command:str),
never payload values, so the--jsondocument'sschema_versionbumps to 2
(captured_events[].payloadremoved,payload_schemaadded;payload_keysstays, now
identifier-gated). Collection hardening rides along: transcript-location components that embed
the username are redacted, the project dir name is aliased in locations, a home-rooted
--binaryhint renders~-relative, and a credential-shaped dict key can no longer surface
in the token key paths. -
BREAKING:
diagnose --jsonandprobe-adapter --jsonnow emit a pure JSON document
(#195). Both used to print their human-readable report with a fenced```jsonblock
appended, so a consumer had to scrape the fence out of prose.--jsonnow emits the
document instead of the report — stdout parses whole, and every human-facing line (ok:
trailers, the leak-backstop warning, theunknown profilenotice) moves to stderr. With
--out FILEthe document goes to the file, stdout stays empty and the confirmation goes to
stderr; no file written in JSON mode carries markdown fences any more. That file is held to
the same standard as the stream — it is validated and newline-terminated identically, so
--json --out FILEand--json > FILEproduce byte-identical files. The text mode (no
--json) is unchanged.diagnostics.SCHEMA_VERSIONdeliberately stays at 1 — it versions the
document, and only the packaging changed — while the probe document gains aschema_version
of 1 alongside its existingversionkey, which still holds the probed CLI's--version
output. Scripts that split on```jsonmust switch to parsing stdout directly; the break is
in the flag's output shape, not in either payload. Two
incidental fixes ride along:diagnose --jsonno longer renders the markdown report it was
about to discard, which was double-counting every leak-backstop repair in the warning, and
the probe document is nowsort_keys-stable so two probes of the same CLI diff cleanly. -
Machine-output (
--json) contract codified inmachine.py. The pure-document conventions
from #190 — one JSON object on stdout, inlineschema_version, additive-only evolution,
errors → stderr with empty stdout — now live in one module with sharedemit/add_json_flag
helpers;status --jsonuses them (output byte-identical) and the duplicated token-total math
folded intorun_token_totals. All four--jsoncommands share the contract (#195);--json
adoption on more commands is tracked in #196. -
Backend-neutral naming for the seam-backed helpers and operator messages. The multiplexer
seam has non-tmux backends now, so the helpers that wrap it drop their legacy tmux names —
launch.tmux_available→mux_available,app._tmux_missing→_mux_missing,
runs.tmux_sessions→mux_sessions(internal, no deprecation aliases) — and the operator-facing
strings stop naming tmux when they mean the selected backend: launch errors say
multiplexer new-session/new-window failedandmultiplexer backend unavailable (binary not on PATH), the TUI notifiesmultiplexer backend unavailable — launch/attach disabledand
launched (control session bmad-loop-ctl), and the "attach to … bmad-loop-ctl" hints say
control session. The TUI-guide troubleshooting table matches. Behavior is unchanged. -
Docs: multiplexer backend guide (
docs/multiplexer-backends.md). The user-facing docs no
longer claim tmux is the only multiplexer backend. The new page covers backend selection
(bmad-loop mux/mux set) and how external backends are installed and discovered;
backend-specific operator guidance (what changes from your seat on herdr, its degradations)
moved out with the extraction and lives in each adapter repo's docs. README, setup guide,
TUI guide, and FEATURES name the mechanism and link the page. -
Docs:
followup_review_recommendedis now scored upstream. BMAD-METHOD#2580 replaced the
skill's convergence-prone significance judgment with a severity-weighted score over patched
findings and added a fourth default review layer (Intent Alignment Auditor, #2560). README,
FEATURES, TUI guide, the[review].enabledsetting description, and the engine's damping
comments now describe the scored flag;limits.max_followup_reviewsis unchanged and remains
the orchestrator-side bound. -
bmad-loop initnow gitignores.bmad-loop/policy.toml. Policy is per-machine-per-repo —
it carries the machine-specific[mux] backendchoice (and the TUI settings editor rewrites
it), so it must not travel to teammates on other machines or OSes. A.gitignoreentry does not
untrack an already-committed file: existing repos rungit rm --cached .bmad-loop/policy.toml
once (the local copy is kept;initprints this hint when it detects a tracked policy).
bmad-loop's own worktree-clean preflight already exempted policy.toml — this additionally stops
inner dev sessions and plaingit statusfrom reading a policy edit as a dirty tree. -
The patch-restore seam is now one validator, one path normalizer, and one exclusion site.
runs.validate_restore_latchholds every latch precondition (sentinel wedge, spec-less escalation,
worktree isolation) — the worktree check lived only in the CLI, sorearm_escalationcalled
programmatically could latch a patch the re-drive can never honor; it now rejects it too.
verify.resolve_restore_pathreplaces four copies of the maybe-relative→absolute join, and the
shared verify gate derives the restore-patch proof-of-work exclusion from the task instead of
threading it in from three call sites. The resolve context'srestore_supportedsignal is now the
validator's verdict too, so the agent never negotiates a restore for a sentinel-wedged or spec-less
escalation either. Otherwise behavior-neutral. (closes #91) -
Test helper fidelity.
make_engineseeds the launching scope (max_stories,story_filter,
epic_filter) onRunStatelikecmd_rundoes, so resume tests no longer silently ran uncapped;
the three_escalated_runfixtures collapse into one parameterized conftest builder. (closes #84)
Fixed
-
Locale-stable rollback (#236). Git subprocesses now run with
LC_ALL=C, sosafe_rollback's
benign "pathspec did not match" no-op is no longer misread as a hard failure under a localized git
(e.g.LANG=it_IT.UTF-8) — which had turned a resolvable re-drive into a rollback pause. Forced at
the single_run_gitspawn point, so every git message the orchestrator inspects stays English. -
The parked-window return target is now backend-composed (#221). An interactive attach
recorded the client's origin as a bare pane id (%N) and replayed it asswitch-client -t %N
from inside the control session — sound under tmux's one-server model, but on psmux (one
server per session, upstream-final per psmux/psmux#483) a bare id is session-local: at best
unresolvable, at worst colliding with a real control-session pane and landing the client on
the wrong one with exit 0, past theswitch-client -lfallback. No single form resolves on
every backend (tmux's window resolver rejects a pane id in thesession:%Nslot, and a
native-id backend needs its own id passed through untouched), so the recording seam now asks
the backend:TerminalMultiplexer.current_return_target()defaults to the bare native pane
id — tmux and native-id backends behave exactly as before — and psmux overrides it to emit
=session:%N, which releases carrying the psmux/psmux#483 fix resolve cross-server,
degrading to the bare id only if the session probe fails. The replay sides treat the value
as an opaque target and are unchanged. -
A
worktree_seedentry that silently copies nothing is now journaled (#230). Under
worktree isolationprovision_worktreecopies a seed only when the destination is absent —
right for a file the checkout legitimately carries, but a directory entry is skipped whole
the moment any child is tracked, soworktree_seed = ["_bmad"]with a tracked_bmad/custom
copies nothing at all, including the absent children that would clobber nothing. Provisioning
is quiet by contract (it runs under the TUI), so the skip was invisible: user-authored config
that reads as applied was a no-op. It now returns the skipped entries and the engine records a
worktree-seed-skippedjournal event; glob-expanded matches are excluded, since a plugin glob
is expected to hit paths the checkout already carries. Behavior is otherwise unchanged —
nothing new is copied. -
A dry run the TUI cannot spawn opens a modal instead of taking the app down (#210). The
run --dry-run/sweep --dry-runworkers calledrun_capturedunguarded, and
@work(thread=True)defaults toexit_on_error=True— so anOSErrorfrom the spawn itself
(a venv deleted out from undersys.executable,EAGAINoff a loaded process table) escaped
the worker and killed the whole app rather than the one modal. Both workers and the validate
degrade now share a guard that reports the reason in the modal body. -
--jsonoutput survives a console that cannot encode it (#200). A JSON document is not
necessarily ASCII:diagnostics.render_jsonserializes withensure_ascii=Falseso its leak
guard can scan values unescaped (#195, below), which lets a non-sensitive non-ASCII field —
a localizedplatform.release(), say — reach stdout verbatim. Printing it to a console whose
encoding could not carry it raisedUnicodeEncodeError, in practice a legacy non-UTF-8
Windows one. It failed safe rather than silently — the encode runs before any write, so
stdout stayed empty instead of half-written — butdiagnose --jsonstill died on a machine
where--out FILEwould have worked.machine.emit_documentnow switches stdout to UTF-8
before writing. Re-serializing the document as escaped ASCII would have been the smaller
change and the wrong one: the leak check verified the unescaped bytes, and emitting anything
re-derived from them is what that helper exists to prevent.--out FILEwas never affected;
it has always writtenencoding="utf-8". -
Leak self-check now matches JSON-escaped values (#195). Two evasions became reachable
the momentdiagnose --jsonstopped also rendering the markdown report, since that raw-text
pass was what had been catching them:json.dumpsdoubles backslashes, so a Windows home
path (C:\Users\…) serialized to a form_ABS_HOME_REdid not match, and its default
ensure_ascii=Trueescaped non-ASCII sensitive values to\uXXXX, hiding them from the
pseudonymizer's stray-original check whilejson.loadshanded the consumer back the
original. The home-path rule now matches either separator form, anddiagnostics.render_json
serializes withensure_ascii=Falseso the guard sees values as themselves. Both apply to
the markdown path too; neither changes what a clean dump contains. -
Resumed runs display the policy they actually enforce (#189).
policy_snapshotwas
stamped only at run creation.resumereloadspolicy.tomland enforces it — the
per-story budget, everySessionSpec— but left the launch-time snapshot in place, and
every display reads the snapshot: the run summary,bmad-loop status, the TUI, and the
policyblock of thediagnosebundle, which claimed to describe the run that was
executed. Editlimits.cache_read_weightbetween launch and resume and the run enforced
at the new weight while every surface reported the old one, silently up to 10x apart at
the legal extremes (0.0–1.0). Resume now re-stamps the whole snapshot and persists it
before the engine starts, restoring the documented contract that policy edits apply to
resumes. A singlesession-endentry could likewise carrytokens_weightedat the
snapshot weight besidebudget_weightedat the live one; the two now agree by
construction. Run scope and mode (source,spec_folder,epic_filter, …) stay pinned
at launch as before — a policy edit still cannot redirect a live run.
Visible output change: a run resumed across a weight edit re-weights its whole
history, not just post-resume sessions, since totals are recomputed from raw counts (this
is what the budget always did). A pre-0.8.2 run with no snapshot at all gets one on its
first resume, so it stops displaying at the hardcoded 0.1 default.run-resumejournal
entries now carrycache_read_weight,policy_changed, andcache_read_weight_waswhen
it moved, keeping per-session totals written under the old weight reconstructible. -
Run summaries and
bmad-loop statusreport weighted tokens, with both units labeled
(#129). The run-finished summary — stdout, theATTENTIONfile, and the desktop
notification all render from one place — reported the raw total, counting cache reads
at full price, while every budget judges the cost-weighted total. On a cache-heavy run
that overstates spend by ~6.5x, and neither figure said which unit it was. Both surfaces
now lead with weighted and name both:<weighted> weighted tokens (<raw> raw incl. cache reads), matching the TUI, which has shown weighted since 0.7.12.bmad-loop statusalso
gained a run-leveltokens:line (it previously printed no run total at all).
Visible output change: per-storystatuscells go from<raw>tto
<weighted>t (<raw> raw), so the number is both differently scaled and differently
shaped — scripts scraping that column need updating. A story with only cache reads under
cache_read_weight = 0correctly renders0, not-(which means no tokens at all).
Displayed weights come from the run's persisted policy snapshot, so every observer
reproduces the same number fromstate.jsonalone. -
The TUI guide's task-table reference described the pre-0.7.12 columns. It documented
tokensas the raw total and omitted therawcolumn entirely; the run-header and
journal sections were likewise silent on the weighted/raw split. Docs only. -
diagnoseleak self-check is now recoverable (#186). A stray pseudonymized
identifier (a per-field routing gap) is repaired by substituting its alias and disclosed
in the report and on stderr, instead of refusing to emit any dump; residual failures name
sensitive[<ns>:<alias>]instead of an opaque index, and the local--legendfile is
written even on refusal so the operator can decode it. PII/secret/path/username hits
still fail closed. -
Deferred-work bundles that adopt an existing story spec pass the baseline gate (#161). A
"follow-up review of story X" bundle is routed bybmad-dev-autointo that story's done
spec, whosebaseline_revisionis the story's original dev baseline — necessarily older
than the bundle's worktree cut, so the exact-match gate failed every such bundle after the
session had already done its work. The bundle gate now accepts a claimed baseline that is
an ancestor of the orchestrator-recorded one (the session diffed a superset of the
unit's changes); diverged or unknown baselines still fail, any git fault in the probe
reads as not-an-ancestor, and sprint/stories modes keep the exact-match requirement. -
A failed attempt inside a unit worktree auto-recovers instead of pausing with in-place
instructions (#161). The mid-drive dev retry was the only recovery path without an
isolation guard: withrollback_on_failure = falseit paused the run with manual-recovery
instructions aimed at the operator's checkout — whose HEAD is the baseline under
worktree isolation, while the commits sat on the unit branch, so following them literally
did nothing and invited a destructive reset of a tree the attempt never touched. A mounted
unit worktree is disposable: the attempt's commits are parked onattempt-preserve/refs
and the worktree resets regardless of the flag, which gates in-place (isolation = "none")
recovery only. The remaining reachable pauses name their tree (git -C "<root>" …). -
A failed worktree teardown no longer crashes the run after the merge landed (#139). When a
process the just-ended session left running (e.g. pytest recreating.pytest_cache) makes
git worktree removefail with ENOTEMPTY, git still drops its admin entry, so theforce=True
retry failed with "is not a working tree" and that secondGitErrorcrashed the run. The
teardown tail ofclose_unit_workspacenever raises now: a failed worktree removal falls back to
rmtree+worktree prune(the rmtree confined to the run's own worktrees dir — the path can
arrive from persisted state), a failed branch delete is reported and swallowed, and both journal a
worktree-teardown-degradedevent — teardown is post-merge housekeeping. A failed forensic diff
capture instead preserves the worktree + branch (they hold the only copy of a dropped unit's
changes).discard_worktreegains the same removal fallback so a stuck dir can't block the
resume re-mount. -
A git call exceeding its timeout no longer crashes the whole run (#156). Every git
subprocess the orchestrator spawns now translatessubprocess.TimeoutExpiredinto
GitError, so the existing degrade guards handle a slow git like any other git failure.
The rollback gate specifically (_rollback_or_pause's dirty check — the reported crash
path) degrades to assume-dirty: rollback OFF pauses with the manual-recovery notice and
the worktree kept; ON / resolved re-drives still auto-recover behind their preserve
steps. Arollback-dirty-check-failedjournal entry records the fault. The bound is now
configurable aslimits.git_timeout_s(default 120). -
Session timeouts now fire on time and leave a forensic trail (#157). A
session_timeout_minthat fired but journaled its session-end 2h19 late — with zero record
of when the deadline was declared or why — is now timely and observable on three fronts.
(1)wait_for_completiongains a wall-clock co-bound: a host suspend (macOS sleep)
freezestime.monotonic(), silently extending the monotonic deadline by the nap's length;
the wall clock keeps counting through a suspend, so it may now EXPIRE the deadline — never
extend it (a stepped-back wall clock changes nothing, and all sub-waits stay monotonic).
(2) The fire moment stamps the result (timeout_fired_at,timeout_expired_clock—
"wall"alone is the suspend fingerprint) and appends atimeout-firedline to
tasks/<id>/session-lifecycle.jsonl; each wait tick tops up a throttled
tasks/<id>/heartbeat.jsonwhose staleness under a still-live session diagnoses a frozen
orchestrator (the previously uninstrumented gap). The engine journals session-end
unconditionally — even a teardown that throws still records the ended session (status
abortedwhen the outcome is unknowable), carryingfired_at/teardown_s/expired_clock.
(3) Teardown is now a verified kill escalation:terminate → wait → force_kill, where
limits.teardown_grace_sbounds the liveness-wait before escalating (default 20;0= a
single unverified best-effort kill) and every escalation step carries its own bound, so a
timeout can no longer hang on an unkillable session. Covers the tmux (generic) and
opencode-httpadapters alike. A frozen
process still cannot run this code while frozen, but recurrence is now diagnosable rather
than silent. -
validateandprobe-adapterno longer report antigravity's hooks as unregistered
(#159). Theantigravity-hooks-jsondialect keys.agents/hooks.jsonby hook-group name
at the top level, with no"hooks"wrapper — but both readers looked up"hooks", got{},
and reported a correctly-installed relay as missing (FAIL: bmad-loop hooks not registered for antigravity, immediately after a successfulinit --cli antigravity). Both now share
oneinstall.relay_registered()helper that resolves each dialect's container shape, so the
two call sites can no longer drift apart.init's merge dedup keys on the narrow bmad-loop
script markers rather than the barebmad_loopsubstring, so an unrelated hook command whose
path merely containsbmad_loopcan't make init skip a registration that validate would then
report missing. -
The antigravity hook relay now reads agy's payload keys. agy encodes hook payloads as
protojson —conversationId,transcriptPath,workspacePaths— while the relay only tried
snake_case plus copilot'ssessionId. Every agy event therefore recorded a null
session_id, andcwdwas never populated (agy sends nocwd, only aworkspacePaths
list). Both the relay and the probe capture hook now try agy's casing, verified against a
live 1.1.3 turn. -
probe-adapter antigravityfinds the transcript. The shipped convention glob had the
wrong filename — agy writestranscript_full.jsonl, nottranscript.jsonl, under
~/.gemini/antigravity-cli/brain/<conversationId>/.system_generated/logs/. Corrected against
a live capture. A live--probenow also prefers thetranscriptPaththe CLI hands the hook
on stdin over the convention glob: the payload names this turn's file, while a glob can
only take the newest match and may land on an unrelated session. -
antigravity:
usage_parser = "none"is now documented as permanent, not pending. A live
capture confirmed agy's transcript carries only
step_index/source/type/status/created_at/content/thinking— no usage block
anywhere. agy does count tokens, but only insideconversations/<id>.db, an undocumented
SQLite/protobuf store outside the(transcript_path) -> TokenUsageparser contract. Runs
work; token columns stay empty. -
antigravity: trust is exact-path (verified against
agy1.1.3).agyblocks on an
interactive "trust this folder" dialog for any workspace not listed verbatim in
settings.jsontrustedWorkspaces; a trusted parent does not cover subdirectories, and
--dangerously-skip-permissionsdoes not bypass it (it covers tool permissions only).
isolation = "none"(the default) works;isolation = "worktree"hangs on every run, since
each worktree is a fresh untrusted path — now called out in the profile and setup guide, and
tracked in #169. Replaces the profile's previous "verify during probe" placeholder. -
Follow-up review sessions are no longer killed on their first Stop by the dev pass's stale
## Auto Run Result(#160). The review leg re-invokes bmad-dev-auto on the finalized (done)
spec whose dev pass left that terminal marker; the review's own entry write lifted it past the
adapter's launch-mtime floor, so the first result-less Stop read the stale marker as this
session's result and ended the review mid-flight (the #109 stall grace never armed). The engine
now strips the marker before every review launch — the frontmatterdonestays, so step-01
still routes to a review pass. The review-budget exhaustion defer reason now reports the last
pass's actual status instead of always claiming a lingering follow-up recommendation. -
branch_per=run+keep_failedno longer poisons a multi-story run after the first kept
failure (#138). The first story to end deferred underkeep_failed=trueleft its worktree
checked out on the single shared run branch, so every subsequent story'sgit worktree add
collided ("branch already checked out") and insta-deferred with zero dev activity — one kept
failure turned an N-story run into a 1-story run. A kept worktree underbranch_per=runnow
detaches its HEAD (git checkout --detach), freeing the shared branch name for the next story
while preserving the working tree, uncommitted changes, and the branch ref (still at the kept
commit) for inspection; subsequent stories mount the run branch normally and get genuine
attempts. Best effort — if the detach ever fails, the existingworktree-open-faileddefer
still surfaces the collision (no regression). The escalate-and-pause path was already safe: it
halts the run rather than continuing, and resume frees the kept worktree before any sibling mounts. -
Dev/review sessions can no longer livelock on their own wake nudges (#149). The idle
wake nudge is delivered as a submitted turn, so a session that merely answers it ends in
another result-less Stop — which refilled the nudge budget, re-armed the grace window, and
repeated untilsession_timeout_min, burning a turn per cycle. Dev/review sessions now get the
same monotonic cap injected workflow sessions already had: afterlimits.dev_stall_nudges_cap
(default 6) total nudges the session is declared stalled instead (post-kill reconcile still
rescues a finished one whose terminal artifact is on disk). The nudge text now also states that
a prose reply cannot end the session. And each result-less Stop leaves a diagnostic breadcrumb
(tasks/<task_id>/resultless-stops.jsonl: pending / not-terminal / stale-mtime / ambiguous /
no-artifact / no-result-json) so why a completed-looking session read as result-less is
answerable from the run dir. -
Split-story keys (
2-6a-…) are no longer silently skipped (#144). The sprint-status
parser rejected story numbers carrying BMAD's split-story letter suffix, dropping exactly the
stories that were split to be loop-tractable — invisible torun/--story/the TUI tree, and
skipped by the epic-lift. The suffix is now a first-classStory/selector field:--story 2-6a
(or2.6a, or--epic 2 --story 6a) selects exactly that half, while a plain2-6selects the
whole2-6a/2-6bfamily in file order.runand--dry-runalso print a stderr warning when
sprint-status keys remain unparseable, instead of only journaling them. -
Review leg repairs a finalize-tail death. A review session that died between writing its
terminal## Auto Run Result(Status: done) and flipping the spec frontmatter off the transient
in-reviewmarker left the orchestrator re-reviewing already-finished work — a burned review
cycle. The review leg now runs the same terminal-status reconcile the dev leg does: when the prose
says done and the frontmatter sits at a reconcilable non-terminal status, it advances the spec to
doneand re-folds the frontmatter'sfollowup_review_recommendedflag (only when present) before
the convergence/damping gate reads it. Bookkeeping-only — every deterministic verify gate still
runs against real on-disk/git state, so it cannot pass uncompleted work. -
Claude sessions launch with
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1(#109). Claude Code
could bias a dev session toward backgrounding its implementation sub-agent despite the
bmad-dev-auto prompt ban; the session then ended its turn to await a completion notification,
and a harness exit at that turn boundary stranded the sub-agent with the story stuck
in-progress→ manual-rollback pause. The shipped claude profile now forces subagents and bash
to run synchronously — the behavior the skill contract already requires. Opt out with a custom
profile in.bmad-loop/profiles/. -
Resume no longer discards a story that already passed its pre-commit gates. A host death in
the COMMITTING window (phase persisted beforefinalize_commitran and the DONE save stamped
commit_sha) matched no resume arm — there is no COMMITTING-keyed session record to replay — and
fell through to resume-restart, rolling back or pausing over fully-verified work. Resume now
finishes the commit in place: thepre_commit_gateworkflows are not re-charged (the persisted
phase is durable proof they passed), thepre_commithook re-fires (message regeneration and
pause veto honored), andfinalize_commit's content-idempotence covers both the pre- and
post-squash crash states. Sweep bundles get the same recovery in_recover_inflight_bundle(#115). -
Resume no longer asks for a rollback of a completed session's committed work. A host death
in the post-verify decision window left the task persisted atDEV_VERIFY/REVIEW_VERIFY,
where the resume replay matcher (which only knew the*_RUNNINGphases) missed the
durably-recorded completed session and fell through to resume-restart — pausing with a
git reset --hard <baseline>instruction that would discard the attempt's finished, possibly
already-pushed commits. Those phases now replay the recorded result through the normal
verify/decide pipeline, and the rollback-OFF manual-recovery notice detects commits above
baseline and leads with saving/checking them instead of a bare reset (#100). -
The TUI no longer crashes on a private-mode CSI sequence in an adapter log. The gemini
CLI's startup burst includes XTMODKEYSCSI > 4 ; ? m; the marker byte sat inside the
params, so the private-marker strip filter missed it and pyte 0.8.2 raised aTypeError
that killed the poll worker — and the whole dashboard. The filter now matches a marker
anywhere in the params, and any escape sequence pyte still can't parse is dropped instead
of propagating (upstream fix exists but was never released — selectel/pyte#202) (#111). -
An unreadable spec no longer crashes the whole run. Every spec read-back — the four verify
gates, the reconcile/sprint/ledger bookkeeping passes, and the generic adapter's Stop poll — raced
the dev skill's own writes, so a transientOSError(a TOCTOU truncation, a lock, an EACCES)
escaped toengine.run()and abandoned every remaining story. Observation now degrades where
repair still raises: verify gates return a retryable outcome naming the read fault (never a phantom
status mismatch), bookkeeping passes skip and journalspec-read-failed, and the read-back poll
treats it as not-yet-terminal, falling through to the existing stall/timeout → post-kill-reconcile
ladder. Review routing re-derivesfollowup_review_recommendedfrom the finalized spec when a
replayed result lacks it, so a fault that skips the reconcile re-fold can no longer silently skip
a recommended follow-up review on resume (#97). -
A resumed sweep re-drives its in-flight bundles by identity, not by bundle name.
SweepEngine
recovered a bundle only from inside_run_bundle, which a cycle reaches after re-deriving the key
from the current triage plan — so a bundle re-armed bybmad-loop resolvesurvived a resume only
because the cachedtriage.jsonreloaded and re-emitted the same name. Lose that cache and a fresh
triage partitioned the ids under new names, silently orphaning the human's resolution. The sweep
loop now opens with_finish_inflight_bundles, mirroring the base engine: every non-terminaldw*
task is re-driven under its own persistedstory_key, before the ledger is read, so its ids leave
the open set and no fresh plan can re-bundle them. A still-escalated bundle stays terminal and
untouched. A missing bundle intent file is regenerated from the task (the verbatim ledger entries
become the contract; the triage prose is the only unrecoverable piece), and a bundle that survives
to a cycle anyway is journaled + notified rather than dropped. Relatedly, a truncated or
wrong-shapedtriage.jsonnow degrades to a fresh triage instead of crashing the whole run — the
corrupt leg of this bug was previously unreachable. New journal events:sweep-inflight-redrive/
-stranded,sweep-intent-regenerated. (#94) -
Run ids are validated, so a run ref can no longer escape the runs directory. A positional ref
(delete,stop,archive,resume,status) was recomposed into a path raw, so
bmad-loop delete ../../xdeleted any outside directory holding astate.json; the hidden
--run-idflag onrun/sweepreached a directory name, a multiplexer session name and a git ref
unchecked. A supplied id must now match[A-Za-z0-9][A-Za-z0-9_-]*(≤ 120 chars, no reserved
Windows device name) — rejected, never sanitized, so ids stay bijective with paths and sessions —
and a ref that is absolute, climbs with.., or carries a separator skips the exact-match branch,
falling through to partial matching over enumerated run dirs only. Ids recovered from the outside
world — abmad-loop-<id>session name, a<kind>-<id>control-session window name — pass the
same validator before they steer a path. Partial refs unaffected (#104). -
An abandoned patch-restore no longer smuggles its files into the corrected story's commit.
Re-arming a story whose previous re-drive had already applied a restore patch snapshotted that
patch's new (untracked) files as pre-existing, so every later rollback preserved them and
finalize_commit'sadd -Aswept the abandoned attempt into the corrected commit. The re-arm now
parses the old latch (verify.patch_new_files) and subtracts its creations from the refreshed
baseline snapshot — the re-drive's own reset then removes them. Best-effort: a missing or
unreadable patch degrades to the old behavior instead of failing the resolve. Commits the
escalated attempt left below the advanced baseline can't be reverted mechanically (the resolve
session's own commits share that range), so they are journaled and echoed to stderr for the human
to classify. New journal events:stale-restore-excluded/-unparseable/-commits.
(closes #90) -
Baseline-era untracked residue no longer vacuously satisfies the proof-of-work gate.
has_changes_sincecounted every untracked file. After an intent-gap halt the saved patch is
untracked residue under the artifact dirs every reset deliberately protects, so a from-scratch
re-arm — which never learns the patch's path — let a re-driven session that produced nothing but a
spec status flip pass the gate on that file's mere presence, andfinalize_commit'sadd -Aswept
it into the story commit. The gate now subtracts the task'sbaseline_untrackedsnapshot. ANone
snapshot (a pre-upgrade run) still counts every untracked file — deliberately the opposite of
attempt_dirty's ignore-all, because a proof-of-work gate has to fail open toward "work happened".
(closes #88) -
The baseline-match verify gate was dead code for generic dev sessions. The gate read the spec's
baseline_commitand skipped itself when that key was absent — butbmad-dev-autostamps
baseline_revision;baseline_commitexists only in the orchestrator's synthesizedresult.json.
In production the check never fired, so a spec claiming a stale or foreign baseline sailed through.
The gate now reads either key, the idiomdevcontractalready used. The test fixture stamps
baseline_revisionlike the real skill does, so it can no longer fabricate the key that hid this.
(closes #89) -
Unit keys with git-ref-illegal characters no longer break worktree runs.
unit_branch_name
builtbmad-loop/<run_id>/<unit_key>from the raw ids, so a key or--run-idcarrying:,..,
@{, a space or a trailing.lockcleared the (already-sanitized) worktree dir only to die at
git worktree addwith "is not a valid branch name". Both segments now go through a new
platform_util.safe_ref_segment— identity for clean ids,-<hex8>digest suffix otherwise, on
git's alphabet rather than Windows' (CONis a legal ref;a..bis a legal filename). A
git check-ref-formatoracle test pins the agreement; theattempt-preserverecovery-ref slugs
now reuse the same sanitizer instead of their own inline one. (closes #102) -
The deferred-artifact stash overwrites its target atomically. A story deferring a second time
re-stashes the same spec filename over the previous one.shutil.movefell back to a non-atomic
copy2there — which tears the stash on a mid-copy crash and fails outright on Windows when an
AV/indexer handle turns the rename into a sharing violation. The stash now stages a copy inside the
destination dir and routes throughplatform_util.atomic_replace, inheriting its win32 retry; the
source removal gets the same retry via a newplatform_util.retrying_unlink, since Windows denies a
delete against an open handle exactly as it denies a rename-over. (closes #101) -
A finished session whose final
Stophook was lost no longer loses its work. A dev/review
session that wrote its terminal spec but never delivered theStopendedstalled— ortimeout,
when hooks were misconfigured and no event ever arrived — and the on-disk result was discarded.
The adapter now re-reads the spec after the window is provably dead, rescuing a self-consistent
successful terminal; every rescue still faces the full deterministic verify, and the journal records
session-rescued-post-killso it stays distinguishable from a live completion. (#95, closes #61) -
A corrupt terminal artifact no longer crashes the whole run. A spec truncated mid-write (a
multi-byte UTF-8 sequence cut in half) raised out of the read-back and past the per-task boundary,
marking the runCRASHEDand abandoning every remaining story. The read-back now degrades an
undecodable spec to "no result yet" — the session retries or keeps its verdict — and the post-kill
rescue additionally keeps its verdict on any read fault, so a best-effort rescue can never make
things worse. The repair path still raises on purpose. (#95, closes #96) -
Windows installs now pull
psutilautomatically — moved from the opt-innon-linuxextra to a
platform-scoped core dependency (sys_platform == 'win32'), so the TUI liveness column no longer
shows every run as?on a stock install. macOS keeps thenon-linuxextra; Linux stays dep-free.
(#72, closes #71) -
bmad-loop-setupno longer deletes live core BMAD config or the installer manifest. In a
multi-module BMAD v6 project the setup scripts hardcodedcore(and--also-remove _config) into
their delete lists, destroying_bmad/core/config.yaml, per-module config, and the whole
_bmad/_config/manifest — breaking futurenpx bmad-method installupgrades. Cleanup now removes
a directory only when it is a verified-redundant skill payload (has aSKILL.md, carries no
config/manifest, and its skills are installed); live config dirs are protected and reported under
directories_protected. The merge scripts read legacy config as fallback but never delete it. Same
root cause as upstreambmad-code-org/bmad-builder#96. (closes #64)