Much of this section is the release/0.9.x hotfix line brought forward onto main (#433).
Upgrading from 0.9.1, you already have those fixes — the entries below re-state them for main,
whose seams had diverged enough that several ports needed a different fix, and they close holes
0.9.1 left open. main has not been released since 0.9.0 and does not descend from v0.9.1.
Added
-
Hook events move out of the project tree (#494). A run's session-completion signals now land
under a user-scoped state root —$XDG_STATE_HOME/bmad-loop,%LOCALAPPDATA%\bmad-loop\state, or
an absoluteBMAD_LOOP_STATE_DIR— so a branch switch, a worktree mount or a rollback can no
longer take a live run's control plane away. Relays predating the move keep working through an
in-tree fallback until you re-runbmad-loop init;validateflags a stale one as
hooks.relay-stale, anddelete/archive/cleancollect the new directory with the run
(clean --jsongainsstate_dirs_swept). -
bmad-loop relay <Event>records a session event without the copied-in script (#494). It
keeps the script's contract: nothing on stdout, rc 0 always, a silent no-op outside a driven
session.initdoes not point hooks at it yet, so no run behaves differently today. -
A coding-CLI adapter class can ship out-of-tree (#226). A profile's new
adapterfield names
a kind resolved against the adapter registry, so a co-installed package can register its own
driving class — and the profile that selects it — with no core edit, as the transport axis has
long allowed.bmad-loop adapterslists the registered kinds and the profiles selecting them,
validategainsadapter.kindandadapter.externalreports, and a broken third-party package
degrades to a surfaced reason instead of breaking selection. -
A deferred-work entry can block stories from running:
gate:. Agate: 3-2, 3-3line names
the story keys that must wait for the entry, and bothvalidate(deferred.hard-gate) andrun
(astory-gatepause) enforce it — the proseHARD GATE:convention stopped nothing. Closing the
entry or dropping the token clears it, sweeps are exempt because a sweep is what closes the entry,
and a gate that can enforce nothing warns asdeferred.hard-gate-unstructured. -
Deferred review findings are harvested from spec frontmatter (#433). A successful dev, review,
repair or review-timeout-salvage pass files each item from the spec'sdeferred:list into the
ledger as### DW-<n>, so findings triaged asdeferreach the sweep instead of stopping at the
spec. Retries and replays never re-file, malformed items collapse into oneseverity: lowentry,
and a spec outside the orchestrator's roots is refused. -
A park record travels with its story's commit (#356). Each parked story writes
.bmad-loop/operator/<key>.jsoninside the story's own commit window, sobmad-loop confirm
works from any clone; the machine-localoperator-actions.jsonindex is retired — still read and
pruned, never written.validatereports a park with no record asoperator.park-record-missing;
pull the park commit's branch if that is what you are missing. -
Stories can park at
awaiting-operator, andbmad-loop confirmcompletes them (#335). A dev
session whose story needs an action only a human can take outside the repo — publish a DNS record,
grant an API key — now commits everything an agent can do, records what is owed in the spec's
operator_actions:frontmatter, and parks so the run moves on, in place of a dishonestdoneor
a run-haltingblocked. Once you have carried the external work out,bmad-loop confirm <story-key>walks the actions (--yesskips the prompts,--reverifyre-runs[verify]first
and blocks on failure,--list/--jsonshow what is parked), advances the spec and board to
doneand commits the pair;[operator] enabled = falsereverts. Run state carrying the new
phase is forward-only — an older bmad-loop rejects it. -
Defer notifications name the branch where the work survives (#333). A deferred story's
rollback parks the attempt onattempt-preserve/*, but the ref only ever reachedjournal.jsonl,
leaving you to hunt withgit log --all; the notice now names the ref and the
git merge --ff-onlythat restores it. Newpreserve_refon each task is projected intostatus
and--json(additive; schema stays 1). -
Stories can close deferred-work entries (#234).
closes_deferred: [DW-5, DW-6]on a
stories.yamlentry or in a spec's frontmatter flips each named entry tostatus: donewith a
resolution:naming the story, at the story's commit. Advisory: a failed or escalated story
closes nothing, bad ids only warn andvalidatereports them up front
(deferred.closes-unknown), and no upstream skill emits the field yet (BMAD-METHOD#2619). -
Readable run logs for
opencode-http(#306). Contributed by
@jackmcintyre. The HTTP adapter has no tmux pane to replay, so
a finished run leftlogs/<task-id>.logholding nothing but the server's own INFO stdout; the SSE
stream now also yields a curated role-prefixed transcript, a<task-id>.server.outand a raw
<task-id>.sse.jsonltrace. -
review.on_timeoutpolicy knob (#271). A timeout-like review verdict (timeout,stalled,
over_budget) burned every review cycle before deferring, even when the dev product was already
finalized and verify-green. Beside the default"retry","salvage-if-done"commits the verified
product and refiles the outstanding recommendation to deferred work, and"defer"gives up on the
first such verdict. -
Transport failures pause the run instead of burning attempts (#194). A session whose CLI lost
its API connection stayed alive but idle until the session clock ran out, so two outages could
exhaustmax_dev_attemptsand defer a story untouched. Adapters classify it from per-profile
env_fault_patternsand pause like anrc 126/127verify fault; re-arming restores the budget. -
Raw psmux premise probes (#488). The zero-token Windows live gate now flags the workarounds a
current psmux no longer needs, so they can be dropped on evidence rather than kept on suspicion. -
python -m bmad_loop(#240). The package is runnable as a module, mirroring the installed
bmad-loopconsole script. -
{story_title}inscm.commit_message_template(#475). The placeholder renders the spec's
title:frontmatter minus any leadingStory <id>:label, so a commit subject can carry a
readable title. It never renders empty and never fails a commit — a spec that is missing,
unreadable or lacks the field falls back to a first#heading and then to the story key, and
charactersgit commit -mcannot take in an argv are dropped.
Changed
-
Docs state the deferred-work contract of the 6.11 era (#567). The sweep's format doc,
FEATURES.md, the setup guide and the module skills now namebmad-build-autoand describe the
spec-frontmatter harvest it uses, keeping the legacy spelling only where a pre-rename install
still needs it. Prose only, no behavior change. -
docs/testing.md states the testing strategy. Layer taxonomy and placement rules, fixture and
ablation doctrine, the quality-guard inventory, and the zero-token and flake policy; AGENTS.md,
docs/README.md and CONTRIBUTING.md link to it. -
The
BMAD_LOOP_*environment variables are documented and centrally registered (#246). The
three runtime override vars —BMAD_LOOP_MUX_BACKEND,BMAD_LOOP_PROCESS_HOST,
BMAD_LOOP_SESSION_TIMEOUT_S— now have a reference table in the README and are read through one
registry, so the supported knobs are discoverable in one place. Behavior is unchanged. -
The supported tmux floor is 3.2. It was never written down, so the only floor a reader could
infer was whatever the argv grammar happens to accept — older than anything the project tests. No
version gate gets added: an older tmux is not refused up front, it is simply unsupported. -
The mid-run config pin covers the adapter kind (#461).
adapterselects which argv builder
runs at all, so it joins theconfig_digestlaunch payload — a driven session that rewrites it
now moves the pin the auto-triggered child sweep gates on, instead of swapping the whole launch
shape underneath it. -
validate's httpx and model-format checks key on the adapter kind, not hooklessness (#226).
Both are facts about theopencode-httpclass rather than about whether a profile registers
hooks, so a hookless profile driven by another kind no longer FAILs with a remedy that installs
the wrong package, nor warns about a naming convention it does not use. Anopencode-httpprofile
carrying a hook dialect now gets the model warning it always needed. -
A hookless profile can no longer wait out the clock on the
genericadapter (#226).generic
completes on a Stop hook thathooks.dialect = "none"never registers, so both routes into the
profile map refuse the pair outright rather than passingvalidateand then idling until
session_timeout_min. A pre-adapteroverlay copied from the packaged opencode profile resolves
toopencode-httpinstead of that dead pairing; an explicitadapteris always honored, and
hookless on any other kind stays legal. -
Profiles from a
bmad_loop.profilesentry point are validated like TOML ones (#226). Both
routes share one invariant set — hook dialect, path containment,env_fault_patternscompilation,
canonicalname/binary/adapter— so a package can no longer install a profile state the TOML
parser would refuse, such as an invalid env-fault regex that trades a load-time error for a silent
never-match at classification time. -
An unreadable deferred-work ledger fails
validaterather than warning. Thegate:hard gate
rides on the same bytes, sodeferred.ledger-unreadableas a warning exited 0 with the gate never
evaluated — a fail-open on the one deferred check that refuses.runpauses on the same fault, so
preflight and dispatch now agree. -
Every spec-frontmatter status read goes through
status_of(#358 follow-up). Five inline
reads remained in the engine and the generic adapter, each taking a blankstatus:as the token
none. One behavior change falls out: a session that erases a previously-set status no longer
records a transition, because a blank is not an observed live status. -
The story token budget is checked while the story runs (#336).
max_tokens_per_storywas read
once, after the story was marked done, so an overrun surfaced only after every token was spent and
a story that deferred or escalated went unchecked. Cumulative weighted spend is now re-checked at
every session boundary, raising one ATTENTION plus a desktop notice, latched per story; still
advisory —limits.max_tokens_per_sessionremains the session-ending cap. -
A failed snapshot blocks the rollback reset (#340). An auto-rollback refused to reset past
commits it could not park, yet journaled a failed uncommitted-work snapshot and reset anyway,
destroying the tracked edits and run-created files that snapshot existed to capture. Both preserve
steps now refuse alike, pausing with rescue instructions naming the tree; inert under shipped
defaults, this bitesscm.rollback_on_failure = trueand in-worktree retries. -
A review that revokes the sprint sign-off escalates (#334).
sprint-statusis advanced to
doneat dev time, so a review session writing it back left the remaining cycles re-reading the
same failure until the story deferred and its work rolled back. The review-verify gate now pauses
with the two ways out — finish and re-arm, or accept and advance the board — under a new
[review] on_status_contradiction(defaultescalate;retryrestores the old behavior). -
bmad-loop-setupstops registering BMAD config; the installer owns it (#258). The skill wrote
the pre-v6.10_bmad/config.yamllayout that BMAD's own resolver never reads, and since v6.10.0
the installer stages the module and regeneratesconfig.tomlwholesale, discarding outside
writes. Its PEP 723 scripts go with it, closing the bare-python3bug (#259); setup now writes
one help CSV, installs the tool and preflights. -
Ctrl+C outside a run exits
130cleanly (#241). AKeyboardInterruptescapingmain()
during config load or engine construction now prints a one-lineinterruptedto stderr and
returns the newExitCode.INTERRUPTED, in place of a bare traceback dumped after any partial
--jsonstdout. The shell exit code is unchanged, but a Python caller'ssubprocess.returncode
now reads130rather than-2; Ctrl+C during a run is unaffected.
Removed
- The
bmad-auto→bmad-looprename shims are gone. The rename shipped in 0.8.0 and no
pre-rename installs remain in the wild, soinitno longer stripsbmad_auto-marked hooks,
deletesbmad-auto-*skill dirs, carries.automator/policy.tomlover to.bmad-loop/, or
prints the leftover-.automator/note, andbmad-loop-setupdrops its migration section. A
project still onbmad-autoshould migrate on 0.9.1 — the last release carrying the shims —
before upgrading past it.
Fixed
-
The settings editor's
review.triggerhelp no longer names a dev primitive that may be absent.
Itsrecommendedblurb said "only when bmad-dev-auto flags it", a name upstream retired in BMAD
6.10.1 but still correct on the 6.10.0 support floor — either spelling is wrong for the other era.
It now says "the dev pass", matching the adjacentreview.enableddescription and the
docs/tui-guide.mdtable it mirrors; thepolicy.tomltemplateinitwrites got the same fix. -
The
version-syncgate no longer passes on a marketplace.json it could not read.
sync_version.check()iteratedmarket.get("plugins", []), so a renamed, deleted or emptied
pluginskey made the loop run zero times and the gate print "ok: every version field agrees"
— green on exactly the corruption it exists to catch, including when a real stale version sat
behind the renamed key. It now refuses apluginsvalue that is not a non-empty list, and
reports a non-object entry instead of raising. Covered by a newtests/test_sync_version.py. -
trunk checkis documented as the changed-files check it is.AGENTS.mdand
CONTRIBUTING.mdboth called it "full lint, no path filter", but baretrunk checklints only
files changed against the upstream — 7 of 254 on the branch that fixed this — and CI's
trunk-actiondefaults the same way.--allis the whole-repo run, and both files now say so.
CONTRIBUTING.mdalso no longer claimsrelease.py checkrejects a hand-authored version
section (it returns 0 on one), tells maintainers thatpreparerefuses until they promote the
CHANGELOG by hand, and stops presenting three commands as the complete set of CI gates. -
Contributor docs now name every obligation CI enforces.
CONTRIBUTING.mdnever mentioned
pyrightor the CHANGELOG, so its verify step — "trunk checkanduv run pytest -qboth
pass" — sent contributors into a CI failure on the dedicated typecheck job. It now carries
uv run pyright, the## [Unreleased]CHANGELOG contract, the WindowsPYTHONUTF8=1
requirement and the Python 3.11 floor, the real extras (tui,non-linux,opencode— it
claimed onlytui), andscripts/release.py's two-phaseprepare/publishflow. The pull
request template gains a matching Changelog section, as does CONTRIBUTING's copy of it. -
The docs no longer describe a state the code has left behind. The README's command table
gainedbmad-loop adapters, and its docs list gained five missing guides plusdocs/README.md,
the full index.docs/FEATURES.mdnow covers all 15 policy sections rather than 12 —[dev]was
documented nowhere. The roadmap's native-Windows entry readplannedwhile its own body said
psmux had shipped, andAGENTS.mdcounted~25leaf modules where there are 28. -
A failed ledger write can no longer empty the deferred-work ledger (#328). Every write built
its replacement in place, so an unencodable value,ENOSPCorEIOpartway through left a
zero-byte ledger with every entry gone. Writes are now atomic — a failure raises with the original
untouched — and a ledger created from nothing lands0600rather than at the umask default. -
A lone surrogate in triage text no longer crashes the sweep (#329). A code point with no UTF-8
encoding at all could reach the ledger through a cached triage result and take down a close path
that had no guard. Every free-text ledger field and the whole of a bundle'sintent.mdnow
neutralize such characters to�, so the text stays visible instead of ending the sweep. -
A
#inside a quoted sprint-status value is no longer rewritten into a comment (#366).
Advancing3-2-x: "a # b"produced3-2-x: done # b", promoting scalar text into a comment the
board never had. A quote-led value is now taken whole with no comment recognized inside it;
unquoted values keep the wide class this board needs and still cede the first whitespace-preceded
#as authored. -
A gitignored sprint board no longer crashes a worktree-isolated run (#350). A worktree checks
out tracked files only, so the board the orchestrator advances was simply absent: the advance
no-opped in silence and the run then died on the same missing file. The board is now seeded into
the worktree and the story's advance re-applied to the main checkout after the merge — journaled
board-advance-carried, orboard-advance-carry-uncommittedwhere git refuses the ignored path
(the ordinary outcome for such a board), orboard-advance-carry-failedwhere the main row never
reached the target. Without that carry the next run reads the main board and hands you the
finished story again. Tracked boards are unaffected. -
confirmno longer loses its commit to a gitignored board (#577). Confirming a park on such a
board printed✓ confirmedwhile the spec's flip todoneand the park record's deletion stayed
uncommitted, dirtying the tree the next run's preflight refuses. The board is now left out of that
commit when git will not take it — a force-tracked board still commits — and is advanced on disk
either way. -
resume's config-change baseline moves out of the agent-writable tree (#498). The baseline
resumecompares against — verify commands, launch binary/args/env, plugin allowlist —
round-tripped throughstate.json, inside the very tree the digest exists to police, so a session
that rewrotepolicy.tomlcould blank the field in the same breath and the warning never fired.
It now lives in the run's out-of-tree state dir (#494) and is collected by the same
delete/archive/cleanlifecycle;state.jsonkeeps a copy consulted only when no
out-of-tree file exists, which covers a run paused under an older version and a project that has
been moved or renamed (#572). This closes the incidental path only — sessions launch with
permission bypass by default and are handed the state dir's location, so a deliberate one can
still silence the warning; closing that needs privilege separation, tracked in #571. -
Worktree runs no longer stall when a seeded hook config carries the main repo's relay (#352).
The seeded.claude/settings.jsonarrived naming a relay path that resolves inside the worktree,
where none exists, so the session emitted no hook events and idled out the session clock. Relay
entries are now stripped from the seeded config before merging, and a project that tracks its
hook config gets the same rewrite pinnedskip-worktreeso the machine-specific command never
folds into a story commit — while pinned, the config is orchestrator-owned and a story's own edit
to it stays session-local. -
cleanupno longer reports a surviving ctl window as removed (#435). Killing a window reports
nothing, so the prune counted every attempted kill as a removal. It now verifies with one
liveness listing and partitions intoremoved/survived/unverifiable, so
CLEANUP_SCHEMA_VERSIONis 2 and text mode names the two non-removed arms on stderr. A
candidate scan that fails outright reportsctl_windows.scan_errorwith empty arms, so a
preflight failure is never the same document as "nothing to prune";sessions.removedis
untouched and still an attempted kill. -
A crashed version probe is distinguishable from "reports no version" (#428). A binary on
PATHthat dies answering-V— corrupt install, AV-blocked exe, hung server — collapsed to the
same answer a quiet binary gives, and its stderr was gone.bmad-loop muxnow prints the dropped
diagnostic as awarning:line on stderr below the table, since the-in the VERSION column
cannot say which of the two happened. -
A project path the OS refuses to canonicalize no longer kills every command (#552). On a
Windows host whose WSL UNC provider is registered but not serving, resolving\\wsl$\<distro>\...
fails outright — and that resolve runs before dispatch, so every subcommand died at the backstop,
diagnoseandvalidateincluded: thehost.win32-on-wsl-pathwarning naming the fault was
unreachable on the only hosts it is for. Those commands now degrade to an absolute path with one
note on stderr and run. Config loading refuses instead, with a typed error every caller already
handles: a degraded root beside a canonically spelled artifact path mis-files an in-tree directory
as external and sends a worktree-isolated run's writes into the original checkout. The paths that
need a canonical answer downstream — run tagging, worktree provisioning, verify — still raise, so
nothing tags one project two ways. -
BMAD_LOOP_SESSION_TIMEOUT_S=infno longer disables the session timeout. The guard rejected a
non-positive value but letinf,1e999andInfinitythrough, producing a deadline that could
never expire — and that budget is the outer bound every stall-grace and wake-nudge window defers
to, so an unattended run could wedge with no backstop left. The value must now be finite and
positive, otherwise falling back tolimits.session_timeout_minas it already did for0and
unparseable input. A large finite value is still honoured. -
A tab in the project path no longer truncates the project tag a window listing carries. The
multiplexer listing is tab-delimited and the tag holds a resolved filesystem path, where a tab is
a legal byte — so the tag came back truncated and read as another project's, and the prune scan
then skipped the project's own parked control windows. The last requested field now keeps its
delimiters. -
Removing a run directory no longer strands a session that outlived its engine (#526).
delete,archiveandcleangated on engine-pid liveness, which an orphan — engine dead, agent
session still alive — passes; for an untagged session the run dir is the last ownership proof a
prune can read, so removing it leaked the session for the life of the machine. Removal now refuses
while abmad-loop-<run-id>session the project cannot prove foreign is live, andcleanleaves
the run untouched;--forceoverrides the refusal and kills nothing, since a session name carries
no project. -
A project path the multiplexer cannot carry no longer strands the scans over it (#419). The
ownership tag held the resolved path, which psmux's control line refuses when it names a spaced
UNC share and which a listing row can split on an exotic separator or fail to decode — either way
the session or window went untagged, leaking oncecleanremoved its run dir and prunable by
another project on a reused--run-id. The tag is now a 16-hex digest of the path, safe on both
transports by construction; pruning still accepts the legacy path tag, so state surviving the
upgrade keeps its ownership; reading a legacy raw tag is the decode half (#380). -
A run id that is a suffix of another no longer resolves to the neighbour's control window.
--run-idis caller-supplied and may contain-, sorun-other-RIDsatisfied the lookup for
RIDand sorted ahead of it —bmad-loop xcould kill the neighbouring run's live orchestrator.
Window names are now parsed and the run id compared whole, as the prune scan already did. -
Attach, return-stamp and kill follow the run's live control window, not an older one (#482).
Window names are not unique, so the lookup answered the first match:a, the return stamp andx
all landed on a parked run's dead window while the live one ran on. Each launch now records the
window id it minted and the lookup prefers it, and a resume whose id was not captured warns rather
than reporting plain success. Adapter authors: the re-prove pairsnew_parked_window's id
with thewindow_idcolumn oflist_windows, which the seam previously left free to diverge — a
backend where they differ degrades to the by-name resolve. -
Diagnose a lost multiplexer session on the crash path (#489). A dead window and a session
destroyed under the run — a reaper, this tool's own prune, an operatorkill-session, a server
crash — both scoredcrashedand read as an agent fault. A crash verdict now probes for the
session and carriessession_vanishedin the failure reason, on every role'ssession-endentry
and as asession-vanishedbreadcrumb, composed with an environment-fault pause. It also reaches
the repair path's exhaustion defer, which otherwise blamed the tree for repairs that never ran.
Diagnosis only: routing is unchanged and a retry re-creates the session. -
A native-Windows install driven from a WSL shell now says so (#332). WSL appends the Windows
PATHto its own, so a bash prompt can reach a Windows-installedbmad-loopthat takes the psmux
platform default and never sees the distro's tmux — whilevalidateprinted a green multiplexer
line and nothing named the platform.validatenow reports the multiplexer selection reason for
every host, soplatform default for win32is on screen wherever the mismatch happens, and a
fallbackselection is a warning rather than a green line. Awin32interpreter working on a
\\wsl.localhost\...project additionally raiseshost.win32-on-wsl-pathnaming the fix (install
with the WSL/Linux Python) and the backend it actually chose;diagnosegainssys.platformand
win32 on WSL distro path. A project under/mnt/cgets a genuine Windows path and no warning.
Nothing changes which backend is selected, norvalidate's exit code. -
A multiplexer-detection failure is reported instead of swallowed (#332).
validatecaught and
discarded any exception from backend detection, so the selection and the backend inventory
vanished with nothing said — while the healthy-lookingmux.backendline above them, which comes
from an independent call, still printed. It now reports undermux.backends-detectedat
warning, carrying the error. -
Provider quota refusals are environment faults on
opencode-httptoo (#323). #194's
classifier lived on the generic adapter only, so its hookless HTTP sibling silently omitted it: a
five-hour provider usage limit read as three stalled stories and burned their retry budgets. Both
adapters now share the classifier, and theopencodeprofile seeds quota, rate-limit and
connection patterns. Those patterns are scanned against the server's own stdout, never the curated
transcript carrying the model's words — a pattern is only sound against a log the model cannot
write to. -
A re-armed escalation no longer overwrites the previous attempt's dirty snapshot (#349). The
preserve-ref names were keyed on the attempt counter, which re-arming resets to 0, so a
post-resolve re-drive rolling back against the same baseline recomputed the earlier rollback's
refname and destroyed the only copy of that attempt's work. A free name is now probed for instead,
suffixing-r2,-r3, …; the scan is bounded and exhausting it refuses
(attempt-worktree-preserve-failed, then the usual pause) rather than reusing an occupied name.
Prune the namespace or lowerscm.preserve_keepif it ever fires. -
The auto-sweep child refuses config a session rewrote under the run (#461).
policy.tomland
profiles/*.tomlsit in the agent-writable workspace and reach host code execution — verify
commands run with a shell, the resolved profile decides the launch argv and env, and
[plugins] enabledgates in-process Python import. A run freezes its policy at launch, but the
auto-triggered child sweep re-read both from disk; it is now pinned to a launch-time digest of
those fields and refuses on a mismatch (sweep-auto-failedplus a notification, the parent run
continues), whileresumere-baselines and warns with the changed categories instead. The digest
is field-scoped, so live-editing[limits]mid-run still works, and plugins are pinned by
allowlist name only — swapping the module behind an already-enabled plugin is still uncaught
(#496, #497). -
The hook relay refuses a redirected
events/dir, andvalidatestats the relay (#461). The
relay's event write followed a symlink — or, on Windows, a directory junction, which needs no
elevation to create — so a driven session could redirect the orchestrator's control-plane event
stream and stall the run tosession_timeout_min. The write now refuses a redirected events dir,
creates the file privately, and writes the payload in full, degrading to a no-op rather than
failing the session. Separately,hooks.registerednever touched the script it points at, so a
deleted.bmad-loop/(a branch switch) read green while every hook event no-opped; a new
hooks.relay-presentfinding stats the relay and saysrun bmad-loop init. -
Dispatched sessions are told the sprint board is orchestrator-owned (#437). The board advances
at dev-verify time but the story commits only after the review loop, so a session dispatched in
between opened on an uncommitted, unattributedsprint-status.yamlchange — one review reverted
it as a spec violation and #334 escalated a finished story. Story dev prompts, the review prompts
of sprint and sweep runs, and every injected plugin-workflow session now carry the prohibition:
never write the board, never revert it, and a row atdoneorawaiting-operatoris bookkeeping
rather than a defect to fix. Review prompts alone add the way out (status: blocked, for a story
that cannot be finished without a human decision); stories mode carries none of it. -
A seed path naming the project root is refused at load, in every source that feeds it (#456).
A root-naming entry made worktree provisioning resolve source to the repo root and destination to
the worktree — both pass its containment checks — so it copied the whole project in, untracked
files included, then recursed into its own destination.""was one spelling of several:.,
./,.\, and on Windows". ","..."and" ", which Win32 trims to the root while
pathlib reads them as ordinary child names.scm.worktree_seed, a profile'sseed_filesand
skill_tree,hooks.config_path, a plugin manifest's seeds and its Python module, and the Unity
seeder's scene guard now refuse every spelling. Behavior change: a non-string seed entry is
rejected rather than silently coerced. -
initno longer follows a config path that leaves the project (#456). The profile and
manifest guards are lexical, so askill_tree,hooks.config_pathor plugin module naming an
ordinary project-relative directory passed them even when that directory linked out of the tree —
and unlike worktree provisioning,initreached mkdir, rmtree and write with no re-check after
resolution. Each now requires the target to resolve strictly below the project — equality would
admit a link back to the root — and a refused skill tree fails the install instead of being
skipped pastinit complete. -
A wrongly-typed field in a profile or plugin TOML is reported, not crashed on. A TOML-legal
value of the wrong type, aninf, or an oversized integer raised past every consumer's error
handling, so the command died with oneerror:line naming neither the file nor the key. Both
parsers now funnel at their load boundary, over a fault set closed on the nine value types TOML
can yield.policy.toml's own conversions are still raw (#474). -
The worktree git-add shield no longer marks a project's tracked files as ignored (#392). It
wrote a pattern for every path it shields, including hook configs and skill trees a project tracks
— where the pattern shields nothing, since git applies ignore rules only to untracked paths, and
its one effect was the tracked-and-ignored state repo-hygiene gates reject, blocking the very
story commit it was meant to protect. Such patterns are now dropped; a tracked directory keeps
its pattern, since that one does hide new children. -
An isolated codex stage no longer runs without the project's hook config (#471). The seed list
and the shield list came from two unreconciled sources, so a profile's hook config was seeded only
if that profile happened to name the path twice — which claude'sseed_filesdoes and codex's
does not. Every non-hookless profile's resolvedconfig_pathis now seeded. -
An isolated sweep bundle can land when the deferred-work ledger is gitignored (#426). A
worktree checks out tracked files only, so a project that gitignores its ledger — the default —
gave the unit none, and the bundle deferred on a fixable retry for ever. Provisioning now seeds it
when the checkout cannot deliver one. A ledger symlinked to an untracked target is seeded to the
wrong path and still hits this (#462). -
Every ledger write an isolated unit makes now reaches the main checkout (#425, #458). A damped
review round's refiled follow-up and a story'scloses_deferred:flips died with the unit
worktree, because the story commit skips the gitignored ledger in silence. Both now re-file after
the merge, journaledreview-followup-carried/review-followup-carry-uncommittedand
story-deferred-close-carried/story-deferred-close-carry-uncommitted. -
A host lost in the merge-to-carry window replays a carry that filed no findings (#433). The
resume pre-pass only replayed units carrying harvested findings, so a unit whose sole ledger
payload was a bundle's closures, a damped review follow-up or a story'scloses_deferred:flips
was skipped and that write stranded. Replay eligibility now names every payload the carry
delivers, and runs before the sweep reads the open set so a replayed closure leaves it before
triage re-bundles. An accepted state sync and an accepted repair session replay too. -
A deferred bundle's closure is no longer carried by the resume replay (#433). The deferral
path deliberately re-files the harvest and withholds the closure — a defer discarded the code that
closure claims to have resolved — but the replay carried it anyway, and since onlyopenentries
are re-bundled a wronglydoneentry is invisible to every later sweep. The replay now mirrors
the deferral exactly: harvest only. -
The ledger snapshot a rollback restores from degrades loudly (#420). A probe that cannot
answer whether the ledger is in scope or tracked now keeps the file (ledger-scope-probe-failed,
ledger-tracked-probe-failed), and reaching the restore unarmed recordsledger-snapshot-missing
instead of passing in silence. The revert is lossless either way: a spec'sdeferred:frontmatter
is never mutated, so the next attempt re-harvests from it. -
A worktree missing its required upstream skills now pauses instead of stalling (#433).
Provisioning skips a skill tree resolving outside the repo — exactly what a symlink to a shared
machine-wide BMad install is — while the run-start preflight stats through that symlink and
passes, so an isolated run was dispatched into a worktree holding none of its skills and every
session stalled onUnknown command. Undelivered paths are journaledworktree-seed-skipped, and
the engine re-probes disk before dispatch. Only the deterministic skill contract can pause a run —
the resolved dev primitive plus the review skills this project'scustomize.tomlrequires;
everything else in the catalogue is copied best-effort and can never pause a run. -
A wheel-bundled skill that lands partially in a worktree is reported (#464). A
bmad-loop-*
skill the copy could not deliver — a checkout file squatting the skill's directory refuses the
whole subtree under per-file no-clobber — seeded nothing and said nothing. Shortfalls are now
re-probed on disk and journaledworktree-module-skills-dropped: informational, never a pause,
because these skills dispatch at the main checkout and their absence in a worktree stalls no
session. -
Worktree provisioning survives a filesystem it cannot fully read (#422). A single unreadable
file, dangling link, symlink cycle or FIFO in the repo's skill trees or seed sources ended the
whole run with a traceback where a named, resumable escalation belonged. Every probe and copy the
isolated seed makes is now total, on one shared walk that descends symlinked source directories
without looping. Only provisioning degrades. -
A worktree's hook config is never registered through a symlink (#421). A checkout carrying its
per-CLI settings file as a symlink out of the tree had the registration read the outside file
and write there — mutating the operator's real dotfile while the worktree, left with no Stop hook,
never reported completion. The whole profile is now refused unless the path is inside the worktree
with no symlinked component on the way to it. -
A renderer stub that cannot compose its prompt now fails the preflight (#410). A stub
SKILL.md(BMAD-METHOD#2601) shells out to a renderer script; when that cannot run it writes
HALT: …and the session stops with no spec — a fact about the install, so every story does the
same. Threeproblemfindings now refuse it up front:skills.dev-rendererfor a short script
unit,skills.dev-renderer-configfor an absent_bmad/config.toml, and
skills.dev-renderer-sourcesfor a missingworkflow.mdor snapshot target. -
Refuse
isolation = "worktree"combined with arepo_rootoverride (#414). The pair produced
a green preflight and then an isolated session with no dev primitive, no result, and nothing
journaled naming the cause.validatenow reports it;run,sweep,resumeand the child
sweep refuse to start; the dry-run banner names it first; the TUI toasts it ahead of its
clean-tree gate. Making the two work together is #443. -
A configured path carrying
[,*or?no longer makes git act on the wrong files (#423).
implementation_artifactsreaches git verbatim out of_bmad/bmm/config.yaml, and git reads a
positional operand as a pathspec — so such a name always matched wider than it named: an
unrelated sibling was staged under a story's name, a changed attempt reported CLEAN, and a
rollback's preserve restore handed back a change the reset had just discarded. Every operand is
now literal. -
diagnosepseudonymizes the spec name, and gives one spec one alias (#433). A journal
record'sspecfield carries the customer's feature name, and a bare basename is
identifier-shaped, so the scrub fallback shipped it verbatim.specnow has an alias namespace of
its own and the value is reduced to its basename first — the producers disagree on shape, so
without that one spec drew two aliases and an absolute home path landed in the local--legend
file. -
The upstream
bmad-dev-auto→bmad-build-autorename no longer breaks a project (#393). The
dev primitive is resolved on disk —bmad-build-autopreferred, a marker-completebmad-dev-auto
accepted — sovalidate,run,sweepandresumepass on either era with nopolicy.toml
edit.skills.base-shimrefuses the forwarding shim as marker-incomplete: it is a valid slash
command, and it HALTs an unattended session on its interactive migration gate. A run mixing
.claude/skillsand.agents/skillsat different eras gets the right name per role, resolved
against the workspace so a run resumed into an existing worktree spells the era that worktree
carries. Mid-upgrade, the orphaned-override warning says to copy the legacy
_bmad/custom/bmad-dev-auto.toml, never rename it, and--dry-runsays on stderr when its
preview is not runnable. -
The git-add shield no longer hides new files in your own checkout (#384). It appended the tool
files it writes — skill trees, hook config, seeded configs — to.git/info/exclude, which is
shared with the main checkout and every sibling worktree, permanent and unversioned; projects
legitimately track those paths, so every new file under them silently stopped being staged by
git add -A. The shield now writes a private exclude in the worktree's own gitdir, activated by a
worktree-scopedcore.excludesFilethat dies withgit worktree remove.
Upgrading from 0.9.1 or earlier: the lines those versions wrote are still there and upgrading
does not remove them — open the filegit rev-parse --git-path info/excludenames, delete the
shield's own lines (typically the skill trees, the hook config, the/_bmadfamily and any
[scm] worktree_seedpath), and usegit check-ignore -v <path>to name whatever is still
hidden. #384 has the full account of what got written and why. -
The shield now proves it applies, or skips with a reason (#384). Storing the worktree-scoped
config key proved only that the value was stored, and an inherited!negation below a copied
pattern cancelled it — either way the tool files stayed stageable with nothing reported. It now
asks git which excludes file actually resolves, re-appends past any negation, and honors an
explicitly emptycore.excludesFileas "no excludes file at all". An unreadable file, an
unresolvable git home, a peer-accessiblecore.sharedRepositoryor an unanswerable probe skips
the shield — journaled, notified. -
Caveats for the worktree shield (#384). It enables
extensions.worktreeConfig, a
permanent repo-format flag; it is rolled back wherever it could be left set without a working
shield, surviving only where a sibling worktree depends on it or the rollback itself failed — the
reason says which. It needs git 2.20: below that the shield is skipped and no repo-format
change is made, since git that old refuses a repo carrying the flag. Concurrent runs serialize on
a.gitlock. -
The git-add shield's git calls run on the shared chokepoint (#389). They were the last bare
gitspawns outside it, so they missed itsLC_ALL=Cpin and used a hardcoded 120s timeout
instead of the configured[limits] git_timeout_s. Both now apply. -
An odd byte in a commit subject no longer crashes the TUI's story checkpoint (#390). The modal
read the subject with a strict locale decode, so a commit whose subject is undecodable in the
run's codec raised mid-render and took the dialog down. It now goes through the git chokepoint and
decodes with replacement, degrading one label instead; a stalledgit logsurfaces as a missing
subject in seconds rather than freezing the UI. -
A non-UTF-8 filename no longer crashes the run past every git guard (#377). The git chokepoint
decoded git's output strictly, and the resulting error matched neither its timeout arm nor its
spawn arm — so it escaped untyped past everyexcept GitError: the merge pre-flight, the
stale-run reconcile at every run and sweep start, andgit diff, whose failure bypassed the valve
that preserves a failed unit's work. It is now aGitErrorlike any other git failure. -
One undecodable byte of verify output no longer crashes the run (#378). Verify commands are
arbitrary operator tools and their captured output was decoded strictly, so a child emitting bytes
invalid in the run's encoding lost every command's result instead of classifying the failure.
Output now decodes with replacement; the tail is display-only, while exit codes still drive
classification. -
A short write no longer truncates the worktree exclude (#375). The update rewrote the file in
place, so anENOSPCorEIOpartway through cut the operator's own excludes mid-content while
the degrade reason still reported nothing written — and the surviving tail parses as valid
patterns, so nothing reported the damage: cut shield lines simply stop shielding, and a cut
landing on a path boundary widens a surviving pattern over a whole subtree. The update is now
atomic — fully applied or untouched. -
The exclude's git query no longer crashes on a non-UTF-8 repo path (#374). POSIX filenames are
bytes, so a repo path carrying bytes invalid in the locale encoding raised out of a
documented-best-effort helper that caught neither arm of it. Git's output is now captured as bytes
and decoded with the filesystem codec inside the guarded tail. -
The worktree's local git exclude is best-effort now (#359). Only its
git rev-parsewas
guarded; the filesystem tail crashed the run on a symlink loop, a read-only.git, or a non-UTF-8
exclude file or seed path. It now degrades to a journaledworktree-exclude-degradedreason —
without the exclude, the unit's commit would fold the provisioned skill trees and tool configs
into the merge. Git unqueryable at all stays a silent, expected skip. -
A spec left with a blank
status:is rescued again (#369). A blank-but-presentstatus:read
as the truthy tokennone, so the prose## Auto Run Resultfallback never fired and synthesis
read inconsistent — and since that consistency gates the post-kill rescue (#61), a session that
lost only its final Stop was discarded as stalled. Blank frontmatter with a proseblockedor
awaiting-operatornow gets a truthful label too; routing is unchanged. -
A blank frontmatter
status:reads as blank, not as the tokennone(#358). YAML parses a
barestatus:line as null, which was then stringified, so every gate saw a token nothing in the
project writes — and a half-finalized generic spec was read as carrying a deliberate custom status
and left untouched. A YAML-null status now reads as empty, the same as a missing key, while a
literalstatus: nonestays the string;confirmrendersstatus: (blank)and the stories-mode
board readspresent. -
Spec writers no longer relay a spec's line endings (#357). The writers read specs through a
universal-newline translation that handed each one an all-LF copy of a CRLF spec, so a write meant
to move one value rewrote every line ending in the file. All four now read bytes and a replaced
line carries its own terminator, so each line keeps the ending it had; a CR-only spec is a clean
no-op through the two strippers. -
A trailing inline comment on a spec's
status:line survives the write (#357). The writers
kept everything through the colon and dropped the rest, sostatus: draft # set by handcame
back asstatus: done. The comment and its separating whitespace now carry through whenever a
conservative token pattern can certify where the scalar ends; anything it cannot read as a bare
token falls back to the full drop, and the value's own quotes are still dropped deliberately. -
A spec status the writer cannot rewrite is no longer a silent no-op (#335). The writer found
its line by prefix while every reader parses the block as YAML, so a quoted key, a space before
the colon or a flow mapping wrote nothing and told no one — while a block scalar, a continued
value or a nestedstatus:was rewritten into corruption. The edit is now re-parsed with
yaml.safe_loadas an oracle and kept only if the block still parses as a mapping, itsstatus
is the target, and every other key is unchanged. -
A status no line edit can safely move now raises instead of reporting success (#335).
False
now means "nothing to change", so setting an already-donestatus returns without rewriting,
while a genuine failure raises: a re-arm aborts before persisting rather than routing the re-drive
to the wrong step. -
A symlink-loop fault at park no longer loses the run's park (#335). Below Python 3.13 a
symlink loop is reported as a type neither guard on the park path held, and the park-index write
ran outside everytryin the commit phase — so an escape skipped the park notification, the
post_commithook and the state save. Both guards now hold the type, and the writer degrades to
itsoperator-index-failedjournal line. -
An unwritable
ATTENTIONfile can no longer crash a run. The notice promised "never raises",
but only its desktop half was guarded — so an unwritable run dir turned an advisory notice into a
run crash at every site that journals a decision and then announces it: defer, escalate, plugin
veto, manual-recovery pause, budget warnings. The file sink now degrades like the desktop one; the
journal entry each caller writes first remains the durable record. -
limits.max_tokens_per_storyis validated like its per-session sibling. It was parsed with a
bareint(), sotruesilently became a 1-token story cap and0was accepted, both against the
documentedint >= 1. Non-integers and values below 1 now raisePolicyErrorat load. Note this
rejects apolicy.tomlthat previously loaded; there is no0 = offsemantic — set the cap high
instead, it only warns. -
A pause inside a defer's rollback no longer loses the defer record (#342). The task reached
terminal DEFERRED before the tree was recovered, so a rollback that paused instead — rollback off,
the default, or a preserve failure — unwound past the tail for ever: nostory-deferredjournal
entry, no defer notification, an under-counteddefer_countindiagnose. The record is now
emitted before the pause re-raises, and its notice points at the ACTION REQUIRED manual-recovery
notice instead of describing a rollback that never ran. -
Spawn-level
OSErroris translated at the git chokepoint (#343). Only a timeout was
translated, so anEMFILE,ENOMEMorENOENTout of the spawn itself bypassed every
OSError-blindexcept GitErrorguard and crashed the run — under exactly the resource pressure
those guards exist for. Spawn faults now raise a typedGitSpawnErrorwith the errno on
__cause__: a fault opening a unit worktree pauses instead of marching the queue into DEFERRED,
and one during merge-target reconciliation keeps the unit branch and escalates. -
safe_rollbackno longer swallows a failedgit stash create(#340). The empty snapshot
silently disabled the wholepreserverestore, so the hard reset reverted exactly the paths the
caller asked to keep — a resolved re-drive's corrected spec — with no error anywhere. It now
raises before the reset, and only where a restore was actually requested, so the callers that ask
for none degrade as before. -
A spawn-level
OSErrorduring the attempt snapshot no longer crashes the run (#343). An
EMFILEorENOMEMescaped untyped out of the middle of a rollback. Preservation is observation
rather than a repair write, so it now degrades into the same journal-and-decide path aGitError
takes, keeping the errno as the breadcrumb;safe_resetstill raises. -
A git fault while counting the attempt's commits no longer crashes the rollback (#343). The
range above baseline was enumerated with no guard at all, so an ordinary git timeout or a spawn
fault took the run down mid-rollback. An un-determinable range now reads as "there may be work
above baseline" and refuses the reset instead of taking the clean-tree early return, journaled
attempt-preserve-enumerate-failedapart fromattempt-preserve-failed. -
The orchestrator's ledger writers no longer inject lines from a multiline value (#305). Found
by @Haven2026 in #274. The deferred-work ledger is line-oriented
but its mutators interpolated their arguments verbatim, so a note could mint a phantom entry,
truncate an entry's span and re-surface its tail as a phantom legacy item, or leave one entry
carrying twostatus:lines. Line breaks in free text now collapse to a space — sanitized rather
than rejected, because a formatting defect must not cost a triage attempt. -
Ledger writers validate the orchestrator-owned fields (#305). A non-ISO
date, astatus
outsideopen/done <date>, or aseverityoutsidecritical|high|medium|lownow raises rather
than landing in the file. The inner dev session and the sweep's migration session still write
ledger markdown directly, so the sweep skill now states the single-line expectation — picked up on
bmad-loop init --force-skills. -
A deferred finding appended after the last ledger entry is no longer lost (#304). Found and
first fixed by @Haven2026 in #274. The inner dev session appends
each defer as a flat block, which the last canonical### DW-<n>entry's span absorbed — so it
was invisible to the sweep, to--dry-runand to the TUI's legacy view. Spans now end at a flat
block, andappend_entrywrites the documentedlocation:field it had been omitting. -
A sweep's return to your terminal no longer claims a hand-off that never happened (#227). The
return answered success unconditionally, discarding the switch's own result, so a failed switch
plus a failed fallback still journaled the return and sent the sweep unattended. The return option
is now cleared only on a real return, a failed one left for the parked window's trailer, and the
two failures report apart:ATTENDED(a human is still there) versusUNREACHABLE(no client at
all), which journalssweep-return-no-client. -
bmad-loop muxrenders a readable table whatever a backend reports as its version (#321). A
probe answering with an embedded newline split every row and stranded SELECTED on a line of its
own; a very long single line broke the table just as thoroughly, since the column widths are sized
off the widest cell. A backend's reported version is now one bounded line — folded rather than
truncated, so the tail naming psmux as the answering binary survives, and capped at 80 characters
— and every consumer applies the same fold defensively, so an out-of-tree backend cannot split a
row or a message. On a psmux host the folded value replaces the previously truncated version
indiagnose --jsonand invalidate --json'smux.backendandmux.backends-detected
findings; the field shapes are unchanged. -
bmad-loop muxexplains a row that is available but unselectable (#321). A backend reads
AVAILABLE because its binary answers here, which is not the same question as whether automatic
selection can pick it — on Windowstmuxis psmux's compatibility shim, so the tmux row looks
like a real tmux install. Gating the column would be wrong, since a forced choice does reach those
backends, so the listing now carries anote:naming them instead. A forced backend is exempt:
calling it unselectable would contradict the*marker one line above. -
Both mux client verbs now owe effect, not dispatch (#317).
TerminalMultiplexer.detach_client
widens fromNonetobool. tmux reads the effect off the exit code; psmux cannot — every arm of
its verbs exits 0 whether or not a client moved — so it measures the session's attached-client
count across the call and answers on the drop, degrading toFalserather than a vacuousTrue
where that is unobservable. Out-of-tree backends still returningNoneread as "nothing detached"
— degraded, not broken. -
Harden the psmux option channel's safety core (#313). The cleanup sweeps matched any
<name>_@<digits>key, so a hand-written@theme_@3died with window@3; keys now carry a
seam-owned marker (@bmad_project__blw@3) only a deliberate imitation collides with.
kill_windowfrees the keys only once a liveness listing proves the kill landed, so a failed kill
no longer strips a live window's project tag and return key, and a free that fails warns instead
of leaking silently. Dev builds only: a window parked by a pre-marker build reads as untagged
and its return move stops firing — restart the ctl psmux server after upgrading. -
Gate the psmux session project tag on transportability (#320). The session tag rode a control
line that stores some project paths corrupted at rc 0 — and a corrupted tag never equals the
caller's again, so the prune skipped that session for ever. The tag now takes the same transport
gate as the window channel: a refusal warns, frees the key and leaves the option unset, where the
prune's run-dir fallback takes over (bounded by #419); accepted writes keep the raise-on-failure
contract. -
Give psmux a working per-window option channel (#310). psmux keeps one user-option scope per
server and answered empty to any per-window read of an@-prefixed name, so the control window's
project tag bled across rows — letting a prune in one project kill another's window — and the
parked-return option always read empty. Both now use a session-scoped key carrying the window id
(@bmad_project__blw@3); theswitch-clientleg stays inert on builds predating psmux/psmux#483,
still inert at 3.3.7. -
Session-qualify the psmux TUI-side window ids (#291). #254 covered the engine seam but left
the launcher's surfaces bare, and that process usually runs outside any pane — where a bare@N
resolves through psmux's most-recent-session fallback rather than the session that minted it, so
the control-window prune could kill another server's identically-numbered window at rc 0. The
launcher's window ids now carry their session, andselect_windowresolves the id to an index
first. -
Verify environment faults are classified per shell, so Windows stops burning attempts (#302).
The{126, 127}convention issh's;cmdhas no equivalent — a missing tool exits1, like
the "tests failed" that should route to repair — so the arm never fired on win32, leaving #130's
charged-attempt regression live there. It now classifies on rc 9009,cmd'sis not recognized/
access is denied, and a probe that also catches a file outsidePATHEXTexiting0unrun — a
pass verify never earned. -
psmux window ids are now session-qualified (#254). psmux mints window ids per server, so a
bare@Nreplayed as a target routed by the caller's own$TMUX— from abmad-loop-ctlpane,
the ctl server rather than the agent's. The log sink then bound to the engine's own window (empty
run logs), nudges went to the engine's pane, and teardown killed it instead of the agent's. Both
verbs now emit the session with the id, degrading to the bare id when the session name contains
:(#221); tmux ids are server-global and untouched. -
A session's read-back could adopt another story's spec (#261). The generic dev and review
read-back picked its artifact by mtime from the shared implementation-artifacts directory — which
under worktree isolation also covers the main checkout's copy — so a foreign spec landing there
after launch won: a review that produced nothing scoredcompleted:done, and a dev leg skipped
review. Unlike the rest of this family (#127/#160/#224) it failed toward landing unverified
work. -
The read-back is pinned to the spec the orchestrator named (#261). Wherever the dispatched
prompt names the path — every review leg, dev repair and patch-restore — the read-back takes that
path and the directory scan is never reached; a sweep bundle's differently-named spec (#161) needs
no exemption. A bare-story-key re-drive and a dev attempt 1 keep the scan, and the skill's no-spec
fallback marker is no longer recorded as a story's spec. -
A dead session with no evidence it ran can no longer be upgraded to
completed(#261). A
read-back artifact is refused unless a turn ended — aStopevent specifically — or the pane log
grew past a small floor; the two are ORed because each has a blind spot. Scoped to the
shared-directory read-back; a task-scoped result stays authoritative. Applies to the crash path
too, journalingreadback-refused-no-proof-of-work. -
validaterequires the review skills your dev primitive really invokes (#260). The preflight
held every project to a fixed catalog naming a skill no tagged BMAD-METHOD release ships,
misdiagnosing it as a missing bmm module even where bmm was installed — sovalidate,run,
resumeandsweepall failed on a stock install. The reviewers now come from the installed
skill itself: itscustomize.tomlreview layers, else what its review step names inline, with
overrides merged as BMAD's own resolver does. -
New
skills.*findings for a review-layer config (#260). A configured layer naming a skill
the tree lacks is askills.review-layer-missingproblem instead of passing and then failing on
every dev run; disabling every layer isskills.review-layers-empty, an unreadable override is a
skills.customize-unreadablewarning, and a layer gated by a run-time condition is
skills.review-layer-unresolved.validateand the run preflight branch on severity, so an
advisory can no longer abort a run. -
Isolated worktrees get the review skills that were validated (#260). Provisioning copies the
skills the project's own layers name, not just the fixed base catalog, and seeds_bmad/custom/—
whose user override layer the upstream installer gitignores — so the preflight and the worktree
run no longer resolve different layer sets. -
notify.desktopworks on macOS/Windows, and warns when it can't (#231). The channel was
notify-send-only, so on macOS and Windows it was inert while defaulting totrue— every "a
human is needed" path reached no one. It now dispatches natively (osascript, a best-effort WinRT
PowerShell toast,notify-send), passing untrusted text through the environment or argv rather
than a command string. With none available,validatewarnsnotify.desktop-unavailableand the
run start prints one. -
Scrollable modal dialogs (#275). The decision, escalation, confirm, sweep-options and
story-checkpoint TUI dialogs now scroll their bodies and dock their action buttons, so the buttons
stay reachable with long content down to the dialog's minimum frame height. Safety warnings that
gate an enabled Resume or Re-arm dock with them, and the Resume confirm re-checks engine liveness
at click time. -
A finished story whose session omitted its result marker no longer DEFER-drops (#224). The
review HALT intermittently finalizes the spec tostatus: donewithout the terminal
## Auto Run Resultblock the harvest scan keys on, so every Stop readno-artifact, stall
nudges re-invoked an already-exited workflow (#149), and aftermax_review_cyclesthe finished,
verify-passing work was rolled back. -
The harvest scan synthesizes a result from terminal frontmatter (#224). It fires once the
spec's (path, mtime, status) fingerprint holds stable across two resultless Stops, and the
post-kill reconcile applies it on a single sighting once the window is provably dead. Synthesized
results carrysynthesized_from_frontmatterand journalsession-synthesized-from-frontmatter. -
Deterministic missing-marker catch and repair (#276). The #224 fallback was heuristic: a
review killed after an mtime bump but before itsin-reviewflip could scoredonewithout
running. The engine now snapshots the spec at review launch and refuses a candidate still hashing
equal to it (unmodified-since-launch), while an observed mid-session status transition proves
the session ran and outranks that gate, collapsing the two-Stop fingerprint to one sighting. -
A synthesized result repairs the spec (#276). The marker the skill owed is written back
atomically, refused outside the orchestrator-owned roots or where the fresh frontmatter disagrees
(spec-marker-repaired,spec-marker-repair-failed,spec-marker-repair-skipped). The launch
status:is never mutated: it is load-bearing routing input to the upstream skill. -
limits.dev_contract_nudge(defaulttrue) asks the skill to repair its own omission (#276).
On the first pending Stop the dev adapter sends one nudge, once per session
(contract-nudge-sent); set itfalseto rely on harness-side synthesis alone.