Changelog
August 19, 2026 · View on GitHub
All notable changes to bmad-loop are documented here. The format is based on
Keep a Changelog, and this project adheres to
Semantic Versioning. While the project is pre-1.0,
breaking changes may land in a minor release.
Unreleased
Added
-
A refused auto-sweep is now visible outside the journal (#501). A run whose deferred-work sweep was refused ended looking exactly like one that swept, and under
[sweep] auto = "run-end"there is one trigger per run that is never re-asked once the run finishes — so the journal was the only trace. Runs now recordsweeps_refused(trigger → reason), surfaced by the end-of-run summary,bmad-loop status,status --jsonandbmad-loop diagnose; the follow-up they name isbmad-loop sweep, which needs a clean worktree. The reason is a fixed slug —not-started,failedordirty— never an exception message, whichdiagnosewould refuse to emit at all. The--jsonkey is additive and always present, soSTATUS_SCHEMA_VERSIONis unchanged. -
validatenow reports a binary that is on PATH but will not run (#294). Theadapter.binarygate askedshutil.which, which a dead WSL/npm shim satisfies — it is a real file with the execute bit — so validate went green on an install that could not start a session, and the opencode adapter's own "binary not found" error sent the user tobmad-loop validateto be told everything was fine. Each binary named by a packaged profile is now run once as<binary> --version; a nonzero exit or a launch fault reports the new check idadapter.binary-unrunnable, carrying the resolved path and the return code. A project overlay's profile is resolved and reported found but never launched: its fields are project-supplied, and validate is the command used to decide whether a checkout is safe to run at all, so a clone's own config cannot choose which binary it launches. The gate bounds which NAME is probed, not what that name resolves to — resolution runs through the user'sPATH, and a probed name resolves to whatever the session launch would itself run. That boundary is the profile's provenance and not the spelling ofbinary, because a bare name still resolves into the checkout whenever a checkout-local directory is onPATH. The severity iswarning, so validate's exit code is unchanged for a live CLI that merely answers--versionoddly, andadapter.binarykeeps its existing found/absent meaning. The check id is additive, soVALIDATE_SCHEMA_VERSIONis unchanged.
Changed
-
probe-adapternow bounds how long a single scrubbed line can be (#481).scrub_textcapped how many lines it emitted but never how long one of them could be, so a single very long line — from a foreign CLI's--version/--help, or from a log tail — reached theprobe-adapterreport and its--jsondocument verbatim:max_lines=5over a 5000-character line still emitted all 5000. Each line is now bounded, and a line that is cut ends with… (N more chars redacted), the same convention as the existing line-count marker. This is a visible output change for any line that long. A cut that would land inside anything the egress guard flags — a credential-shaped token, or a URL credential whose match ends at the@— retracts out of it and drops it whole: cutting mid-construct could otherwise leave a fragment that keeps the sensitive part while no longer tripping the rule, turning a fail-closed refusal into an emission carrying part of the credential.probe.SCHEMA_VERSIONis unchanged: no field is removed or renamed and no type changes, and the meaning of the value was already "the CLI's scrubbed output, capped" — adding a second axis to an already-documented lossy cap is the same class of value, not a new one. -
Files the orchestrator replaces by name now land at
0600. Those writes passfollow_symlinks=False, and that mode deliberately carries nothing over from the target — not its permission bits, not its xattrs — so the new contents arrive atmkstemp's private default. Affected:.bmad-loop/decisions.json,.bmad-loop/policy.toml, the park records under.bmad-loop/operator/, and any story spec written throughset_frontmatter_status,set_frontmatter_fieldordevcontract. Most were already being reset to0644on every rewrite — the hand-rolled temp they replaced was created at0666 & ~umaskandos.replaceswapped that inode into place — so for those the delta is0644→0600. Git records no mode but the exec bit, so nothing downstream of a commit notices.The no-follow choice is made per file, not per call site: once one writer of a file replaces it by name, every writer of that file has to, or they disagree about what the path means. Applying it that way brought four writers into line that had been direct
write_text/write_bytescalls —verify.safe_rollback, the engine's park-record restore,set_frontmatter_statusandset_frontmatter_field. Those four opened the name, and so wrote through any symlink planted at it; they now replace it. That is a real change at those four, and what makes it safe is the same per-file rule: each of those files already had a name-replacing sibling writer (policy.write_mux_backend,record_park,devcontract._atomic_write_spec), so no link at those names survived the orchestrator anyway. Files with no name-replacing sibling keep following symlinks —sprint-status.yamland your CLI'ssettings.json, both operator-curated, so a board or a config kept outside the tree and linked in keeps being a link. -
atomic_write_bytesacceptsfollow_symlinks, as its text sibling already did. The default staysTrue, which the private-git-exclude writer depends on: it pre-creates the target precisely so the helper has a umask mode to carry over, and git silently ignores an exclude file it cannot read.
Fixed
-
The egress self-check now sees Windows→WSL UNC home paths (#512).
diagnoseandprobe-adapterre-scan their own rendered bytes before emitting and refuse to emit at all on a hit, but the absolute-home-path rule knew only forward-slash spellings — so a path reached through the Windows→WSL UNC bridge (\\wsl.localhost\<distro>\home\<user>, the legacy\\wsl$\..., the extended-length\\?\UNC\...folding) was invisible to it, including through the--jsonrender, where every backslash is doubled. No released version leaked such a path: since #485collect_envreduces the project path to a boolean and noEnvInfofield carries it. What was wrong was the claim — thediagnosticsmodule docstring described the backstop as fail-closed without qualification — and that docstring is corrected in the same change to say what the guard is, a shape re-scan of the rendered bytes, and what stays outside it: a home spelling it does not know, or a username that is not this process's. -
The
diagnosepolicy snapshot's verbatim-key invariant is now enforced (#202). The snapshot emits dict keys unredacted, which is correct only while no policy section is a free-keyed table — the one user-keyed table,plugins.settings, is intercepted before it reaches the passthrough. Nothing enforced that property, and a value-level check could not: a newly declared free-keyed section defaults to an empty dict, so such a check stays green while the hazard is live. A test now fails at declaration time, the moment such a section appears, and the reason is written down at the passthrough. No behavior change. -
The zero-token OpenCode live smoke skips stale or broken shims (#294). Its availability gate now requires
opencode --versionto succeed before starting a server; runnable installs still fail loudly when the pinned API contract drifts. -
Policy loading now enforces the declared timeout and result-less Stop nudge minima (#648). The valid
session_timeout_min = 1andstop_without_result_nudges = 0boundaries remain accepted, while smaller values now raisePolicyError. -
Reject mismatched TOML scalar types for every
limits.*policy field (#278). Quoted booleans and other coercible values now raisePolicyErrorinstead of silently changing the configured limit or enabling a disabled behavior. -
Attempt-owned spec-only retries no longer demand a false manual rollback (#123). A bound plain attempt whose only residue is its lifecycle flip is restored to its pre-attempt lifecycle status and proven Git-clean before retry. A resolved re-drive may instead retain an authorized dirty, human-corrected spec and reports
rollback-owned-spec-normalizedwithout claiming cleanliness or auto-committing the correction. Each bound retry chain now snapshots its first spec input byte-for-byte and retains it across dev- and review-verification repairs, so a later failed child cannot replace that input with its own body edits. Non-fixable retries park the failed child, restore the snapshot, and then re-establish the promised lifecycle route after resetting sibling residue. Repair prompts validate retained authority before they can reset a spec, and a plain child that puts a tracked spec back at Git baseline cannot erase pre-launch operator edits. Pre-existing untracked and ignored bound specs use that snapshot as their independent dirtiness oracle and are force-included only in the private recovery ref before restoration; index-only force-adds and cached removals also trigger cleanup, which restores the baseline index ownership.rollback-owned-spec-restoredrecords the repair. Missing, unreadable, retargeted, changed external, or unsafe legacy authority pauses with convergent spec-adoption instructions, and recovery refuses a reset whose baseline would replace the canonical path or a parent directory with a symlink, tree, file, or other unsafe shape. An initial Sprint observation fault may leave a bare-key attempt unbound; an existing Stories folder+id target instead aborts unless it can be snapshotted. Post-bind faults abort before launch while retaining the authority for recovery. Protected rollback inventories and deletion replay remain byte-safe for POSIX filenames outside UTF-8. Snapshots are retired after commit. Other substantive changes and sibling source, artifact, or untracked residue still follow the configured rollback policy. -
Recorded sprint re-drives now route directly through their known spec (#630). Only a generic sprint task with a recorded
spec_filenames thatready-for-devspec explicitly and pins deterministic read-back to the same file. Fresh sprint tasks with no recorded path remain bare-key dispatches; Stories stays folder+id, Sweep stays intent-bundle, and patch-restore plus verification-feedback routes retain their existing wording and precedence. A filesystem fault while observing the dispatch binding leaves that attempt unbound instead of aborting the run. -
Accept canonical reachable-descendant spec baselines without trusting untracked residue. An exact recorded baseline remains valid; any different real claim must uniquely resolve from 7–64 hex characters to a direct immutable commit that descends from the recorded baseline and is reachable from
HEAD. Symbolic or movable refs, ambiguous prefixes, non-commit objects, older, diverged, unknown, and off-HEAD claims remain refused, apart from the existing deferred-work bundle ancestor exception. Proof is re-anchored after an accepted descendant and counts only tracked, staged, or committed changes because the launch snapshot cannot date untracked files relative to that later claim. Shared-checkout mode proves later tracked work exists but cannot attribute it to one session; worktree isolation preserves that provenance. This accepts a skill stamp made after an intervening commit without letting stale untracked residue satisfy the gate. The same mismatch in the opposite direction is #640. -
Overlong lowercase/kebab sweep bundle labels now proceed deterministically (#503). An otherwise valid name over 40 characters is truncated to 40, journaled and persisted before validation, so the sweep proceeds without spending a feedback retry or pausing the run. Post-truncation collisions and other invalid shapes still fail validation.
-
Silent OpenCode parent sessions now enter bounded recovery from launch (#411). Parent/child SSE activity and a provably busy or retrying parent re-arm the grace; true silence spends the bounded nudge budget and is classified
stalled. This neither prevents upstream subagent interruption nor guarantees that best-effortprompt_asyncnudges wake it. -
Generic and OpenCode dev/review stall grace now arms at launch and re-arms on activity (#470). Pane/SSE activity and later turn-end evidence restart the grace, so a truly silent live session spends its configured bounded nudges and becomes
stallednear the grace sequence instead of necessarily consuming all ofsession_timeout_min. Completion still requires Stop/idle evidence or window/server death plus deterministic artifact verification; prose never completes a session. -
Dead targets no longer abort the generic completion loop from its two nudge sends (#504). The stall and result-less-Stop sends alone contain
MultiplexerError, after which deterministic liveness and artifact logic decidescrashedorstalled; this is not blanket multiplexer failure tolerance. -
Path-resolution refusals no longer tear down worktree runs mid-provisioning (#556). Observation probes report coarse unknown entries, each refused seed is skipped independently, provisioning-root uncertainty raises a typed escalation and pauses before any write or session, and mount uncertainty defers only the affected unit. Required upstream-skill absence still escalates; this boundary does not include
ProjectPathsor confined cleanup resolution. -
Rollback path uncertainty now triggers a pre-destructive rollback pause (#557). Trusted spec containment fails closed, observational exclude and story derivation use explicit fail-open or empty fallbacks, collision uncertainty keeps and escalates the branch, and exact commits omit only an uncertain candidate after a trusted root is established. This is limited to the audited verify and recovery boundaries, not every path-resolution call.
-
The dashboard now survives project-root resolution refusal with unavailable panes (#558). Startup and polling retain empty or unavailable views through one stable lexical/canonical cache spelling. This preserves the dashboard around a dead provider; it does not recover the provider or make it operational.
-
Advancing a sprint board now preserves every authored line ending (#576). A valid UTF-8 board keeps each CRLF, LF, bare-CR or mixed per-line terminator while the requested story, conditional parent-epic and optional
last_updatedvalues still change. Previously the read/write relay could translate the whole board to the platform line ending, producing newline-only diff noise outside those intended value edits; parsed YAML content was not corrupted. -
A
sweep --migraterewrite that drops agate:token a pre-existing entry declared is now refused, instead of un-gating that story silently (#519). Validation held the rewrite to each entry's id and status only, so losing the token passed, got committed, and the next dispatch ran the story the entry existed to hold back. The original ledger is now restored and the session re-prompted with the error, escalating aftermax_migration_attempts— a rewrite that previously passed can now fail. An added token is still accepted, since over-blocking fails loudly and in the safe direction. Migration-scoped: every other ledger edit is still held by the format doc's instruction alone. -
sweep --migratenow refuses a ledger that already carries duplicateDW-<n>ids, rather than migrating it into silent data loss (#519). One id naming two entries cannot survive a migration either way: the rewrite that keeps both is rejected as duplicate, and the one that passes collapses them and drops a twin'sgate:. The sweep now pauses before any rewrite is dispatched, naming the ids to renumber, and resumes with its migration budget intact. A deliberate behavior change beyond #519's original scope: such a migration completes today by losing that data, and will now stop instead. Bounded to ledgers that already carry duplicate ids. -
An unrelated untracked file in your main checkout no longer blocks every isolated unit merge (#460). Under
[scm] isolation = "worktree"the pre-merge reconcile refused over any dirty path outside the unit branch's incoming set, untracked included, so a singlenotes.txtescalated the story and paused an unattended run. A merge writes only the paths that differ between target and branch and never stages an untracked file into the commit, so untracked dirt outside that set is now left where it is, the merge proceeds, and the run journalsmerge-target-toleratednaming it. Uncommitted changes to tracked files still escalate —merge_strategy = "squash"would fold them into the story's commit. Reaches dirt that appears after the run starts, and everyresume. -
The escalation that remains no longer blames a game engine (#460). It asserted that "likely a Unity Editor wrote into the main project" on every isolated merge — the guard's origin story rather than a diagnosis, fired on repos with no Unity anywhere and no plugins loaded — and told you to "clean them", which is the exact verb the reconcile performs with
unlinkon its own paths. It now names the tracked files, asks you to commit, stash or revert them, and demotes aper_worktreeengine Editor to one possible cause beside ordinary local work. -
A git that warns while still exiting 0 no longer corrupts the answers the orchestrator reads back from it (#442). An unknown
core.fsyncMethodvalue or acore.fsmonitorhook that cannot exec is a property of the host's git config rather than a failure, but the shared git helper handed callersstdoutandstderrmerged — so every probe that read git's text as the ANSWER took the advisory for data. Silently: a rollback candidate list grew a phantom path, a commit list grew a phantom sha, and the sha and branch probes answered with the warning appended — the last of which reached the baselines persisted in run state, so a resume graded a warning-carrying string against a clean one and read "moved". Loudly: preserve-ref retention raisedPrunePreserveError,commit_pathsraised where its contract reports nothing to commit, and the worktree snapshot failed outright — which since #340 is a gate and not a safety net, so a noisy host had no working rollback at all. Thirteen value-reading probes now take stdout alone; the ~45 call sites that only check a return code are unchanged, and a failing git call still carries its stderr. -
An indented, tab-separated or empty heading in the deferred-work ledger no longer lets a
gate:below it hold a story the entry never named (#516). A canonical entry ended only at a#run at column zero with exactly one space after it, so three shapes CommonMark spells as ATX headings — up to three spaces of indent, a tab as the separator, and an empty heading — ran on past the section header and absorbed the next section's field lines. A latergate:then landed in an open entry that had declared nothing:validatefailed (deferred.hard-gate) andrunpaused (story-gate) on a story that entry never gated. Two reads move with the boundary: astatus:below such a heading is now that section's rather than the entry's, so the entry parses with no status and leaves the open set; and a tail below one is no longer masked by an over-long span and reaches the legacy parser again. Four spaces of indent or a leading tab is an indented code block rather than a heading and still ends nothing, and a heading nested in a list item or a blockquote (- ## Notes) is still not a boundary either — the same line-scope limitation as #327. -
Launching with a
--run-idthat already names a run is refused (#602). The flag pointed at an existing run adopted that run's directory and published its ownstate.jsonover it. The id is now claimed exclusively as the directory is created, so a collision aborts the launch before anything is written and the earlier run is left untouched. -
A launch that aborts while standing up its adapters no longer strands an empty run (#602). An adapter failure left a run directory with no
run-startthat nothing reconciled, so it lingered inbmad-loop listlooking resumable. Composition is now atomic from the first published artifact: on any escape the run directory and its out-of-tree state are removed and the original error is re-raised unchanged. The removal keeps the live-session guard, and a removal that itself fails is now reported instead of passing silently. -
A failing auto-sweep can no longer kill its parent run, and a stop during one is no longer swallowed (#600, #601). A
SystemExitfrom the child — what an unusable multiplexer or an unresolvable profile raises — escaped every handler and ended the process at exit 1, leaving the parent neitherfinishednorcrashedwith an orphaned agent session. In the other direction a stop was recorded as a child failure, letting the parent run on tofinishedand leaving it unstoppable.KeyboardInterruptdeliberately still escapes. -
A run's
sweeps_triggeredrecords only auto-sweeps that actually started (#501). The trigger was spent before anything had been attempted, so every way a child could decline to launch consumed it — including a plaingit statustimeout, which fails closed and could silently burn a run's one and only sweep. The tree check now runs ahead of the record and journals areasondistinguishing a git fault from real local changes. Not a retry: what this buys is thatbmad-loop diagnosestops reporting sweeps that never ran. A child that fails after its run directory exists still spends the trigger; the never-launched case journalssweep-auto-not-startedand keeps its own notification. -
A failed write can no longer truncate the sprint board, a story spec, your CLI settings or your policy file (#379). Seven writers read a file, merged into it, and wrote the whole thing back through a truncating
Path.write_*— so a fault partway through (ENOSPC, EIO, a quota) published a prefix of the file and destroyed the rest. All seven now go throughplatform_util.atomic_write_text/atomic_write_bytes: contents to a temp, fsync, replace.Most of these fail quietly — the file still parses afterwards, so nothing downstream raises:
- The sprint board.
sprintstatus.advanceis the orchestrator's sole write path tosprint-status.yaml. YAML cut at a line boundary is still a valid mapping, just a smaller one, so the epics past the tear ceased to exist and the run walked off the end of the sprint instead of erroring. - A story spec. A spec is laid out
before + edited + after, so a write that faulted after the frontmatter had landed left intact frontmatter sayingstatus: doneover a decapitated body — a spec that lies, which the loop then commits.set_frontmatter_status,set_frontmatter_fieldanddevcontract's four in-place rewriters now share one call.devcontractwas already atomic and recorded the measured cost: fault injection on the old truncating write cut a 46-byte spec to 12. .bmad-loop/policy.toml.safe_rollbackcaptures it before thegit reset --hardand writes it back after, so your orchestration config survives a rollback whether or not it was committed. A truncated TOML is not a smaller config — it is a parse error the nextrunrefuses on, which is the failure the whole restore exists to prevent.- The park records. The engine restores a story's record when the commit it was written for fails. A record left truncated reads as a park owing nothing, while the board still says a human owes something.
Your CLI's
settings.jsonis the loud one. Both writers that register the completion hook —initand each isolated worktree's provisioning — parse the whole config, merge the relay into it, and write all of it back, so your permission allowlist,env, MCP entries and your own hooks survived only by round-tripping through that one call. JSON has no partial read — a prefix of an object is a parse error, not a smaller object — so this was never silent, just unrecoverable:initrefused on the next run withis not valid JSON; fix it and re-run init, pointing you at a file it had shredded itself, and refusing before the merge meant re-running could not rebuild it. In a worktree it was quieter and worse — nothing re-reads that copy, so the session started against unparseable settings, the CLI fell back to its defaults, the Stop hook was never registered, and the run idled to timeout with nothing naming the cause.Three of the seven are put-backs: they restore a file after something has already failed, which is the worst possible moment for a second loss. One of them, the engine's restore of a park record after a failed commit, now journals its own failure as
park-record-rollback-failedrather than dropping it. Nothing else would ever surface that —validatereports a board parked with no record, but never a record left over for a park that is in no commit. - The sprint board.
-
Four writers under
.bmad-loop/no longer strand an untracked temp when their replace fails (#363). Each built a fixed-name temp and thenos.replaced it, andatomic_replacedoes no cleanup of its own. No ignore rule covers those names —initwrites.bmad-loop/runs/,.bmad-loop/cache/,.bmad-loop/policy.tomland_bmad/render/— so a failed replace left an untracked file holdingverify.worktree_cleanFalse until a human deleted it by hand.- Three route through the helpers, which name the temp uniquely per write and remove it on any
raise: the pre-answer store (
.bmad-loop/decisions.json),policy.toml's[mux] backendwriter, and the TUI settings editor's save. The last two built the same.bmad-loop/policy.toml.tmppath, so they could also collide with each other. archive_runkeeps writing its own tarball — the path goes totarfile.open, so there is no payload for a helper to take — and gains the unlink-on-raise guard instead. Its temp was also misnamed:with_suffixreplaces only the last suffix, so<id>.tar.gzyielded<id>.tar.tar.gz.tmp. It is now<id>.tar.gz.tmp.
Under a monorepo
repo_root:override this surfaces as a failingvalidateand TUI rather than a blockedrun:runandsweepprobe the repo root,validateand the TUI probe the project.Five more temp-and-replace writers took the same helper without having been exposed: the sweep's two
decisions.jsonwrites, whose file is the per-run one under the gitignored.bmad-loop/runs/<id>/and not the project-level store named above; the two park-record writers behindbmad-loop confirmanddrop, which already carried a hand-rolled guard; anddevcontract's spec writer. All five gain the fsync before the replace and a unique temp name in place of a fixed.tmpsibling that two concurrent writers would collide on.Scope, for both entries above. This closes the raise path, and only that. The helpers stage at
mkstemp(dir=target.parent, suffix=".tmp"), so aSIGKILLmid-write still strands a temp thatworktree_cleanreads as dirty; and on Windowsos.replaceisMoveFileEx(MOVEFILE_REPLACE_EXISTING), which is not guaranteed atomic and may fall back to a non-atomic copy. What holds everywhere is that a failed write cannot truncate the original. - Three route through the helpers, which name the temp uniquely per write and remove it on any
raise: the pre-answer store (
-
An atomic write no longer fails on a file whose name fills the filesystem's limit. The helpers stage a temp named after their target, and
mkstempinserts eight random characters, so the temp ranlen(name) + 13— long enough that a target with a perfectly legal name produced an illegal temp name and the write died withENAMETOOLONG— or, on Windows, withENOENTcarryingwinerror206, which is the same condition under a different name. On ext4 the cutoff was a 243-byte basename. Latent in the helpers since they were written, and reachable from this release because the story spec writers moved onto them: a spec is named by your planning skills, and nothing bounds that name. When the readable temp name cannot fit, the helpers now fall back to a short digest of it rather than failing — the OS decides, so there is no guess at a per-filesystem byte limit. -
A best-effort diagnostic write no longer takes the run down, and POSIX tmux captures no longer raise on an undecodable byte (#380). Both halves are one defect: an exception net that names one direction of the Unicode hierarchy while the other escapes it.
_append_diag_jsonlserializes withensure_ascii=Falseand writes through a UTF-8 handle, so a story spec whose filename holds a byte the filesystem codec could not decode arrived surrogate-escaped and failed at the encode withUnicodeEncodeError— aUnicodeError, so aValueError, not anOSError— straight past a guard whose stated contract is that an unwritable run dir must never break the completion loop. The guard is now(OSError, UnicodeError)and the breadcrumb is dropped instead of crashing the run. Separately and visibly:tmux_base's_ERRORSwas the strict handler on POSIX while the Windows backend already set"backslashreplace", so a capture holding such a byte raised out of the one place tmux is spawned; it now comes back with that byte rendered as a\xNNescape, and a session name or window title carrying one reads back escaped. Fixing the decode at its source rather than at the fourteen catch sites is deliberate — most of them return a sentinel, so catching there would have converted a crash into a wrong answer ([]reads as "window dead")._ENCODINGstaysNone, so POSIX keeps the locale codec and only stops being strict. This closes the diagnostic writer and the capture decode; it does not sweep the widerexcept-tuple family. -
A plugin hook or version probe whose child emits a non-UTF-8 byte no longer crashes the run (#383).
subprocess.run(..., text=True)decodes with the locale codec aterrors="strict", and theUnicodeDecodeErrorthat raises is neither anOSErrornor aSubprocessError, so it escaped both captures' guards. The declarative-hook transport is the run-crashing half:_HookErroris the bus's designed channel for "the hook could not run to completion", and the decode happens after the child has exited — so a hook that actually passed took its exit code down with the run instead of being classified.probe.run_version_helpdocuments "Never raises", and a--version/--helpbanner carrying such a byte made that false. Both now decode witherrors="replace", so the hook's verdict survives and a fault is classified through the normal channel. Visible change: those bytes now show as�(U+FFFD) in journal entries, hook advisory text and probe documents. Theexcepttuples are deliberately not widened — the fix is to stop raising, and a widened tuple would guard a fault nothing could ablate into existence. -
A session whose log merely writes about a provider error no longer pauses the run (#507).
claudeshipped one pattern joining a framing token to a cause across an unbounded.*, and this adapter scans a tmux pane capture — the model's own output — so any line mentioning both halves classified. 44 of this repo's own tracked lines did, most of them ordinary pytest/grep/diff output. The harm ran the other way: a genuine timeout was read as an environment fault, re-arming the budget and halting the run for an operator instead of charging the attempt. The three replacements reproduce only complete error sentences Claude Code was captured printing, 5xx statuses enumerated rather than ranged. A standing guard walksgit ls-filesand asserts no tracked line classifies — write the framing token and a cause separated (API Error…<cause>) in future entries. -
A mid-response connection drop or a provider 5xx refusal on the
claudeadapter now pauses the run with the matched evidence (#323). The withdrawn connection-only pattern caught 2 of the 5 captured Claude Code failure lines, so a session killed by either of those three misses was charged a dev attempt, andmax_dev_attemptsof them deferred a story whose spec and code were fine. All five classify now, and the pause re-arms the budget instead of spending it. A subscription usage-limit / quota refusal is still not classified on this adapter: no captured Claude Code quota line exists to seed a pattern from, and on a pane capture that vocabulary is what a story implementing rate limiting prints all day — follow-up: #610. The four unseeded profiles (codex,gemini,copilot,antigravity) are unchanged and still ship none. -
An out-of-tree module that claims a bundled transport's name is no longer driven in the bundled backend's place (#565). Backend selection breaks ties on registration order, and the bundled
tmux/psmuxentries were seeded only on the first multiplexer resolution — so any registration that landed before it owned that name, and the whole process silently ran on a third-party transport. The trigger is reachable rather than theoretical: a plugin's[python]module is executed in-process by the plugin registry, which has no ordering relationship to when the mux is first resolved. Registering a backend now seeds the bundled ones first, making builtins-first an invariant of the registration itself rather than a property of which import ran first — the same fix the adapter registry already carries. A shadowing external is still registered, just behind the bundled entry. -
Two broken distributions that publish the same entry-point name no longer collapse to a single recorded error (#566).
importlib.metadatadoes not deduplicate entry points across distributions, and all three external-load scans — adapters, profiles and mux backends — recorded failures by assignment into a name-keyed map, so the second failing package overwrote the first. You fixed the one failure you were shown, then met the other on the next run with nothing saying it had ever been there. Both reasons now surface, and every recorded external-load failure names its distribution when one is resolvable — inbmad-loop adapters,bmad-loop mux,validate's human output andvalidate --json— because the entry-point name is not the distribution you uninstall.The limit, plainly: two failing same-named distributions still produce one finding and one warning line, whose text now carries both reasons
"; "-joined. The row count does not double. That is what keeps the change contract-preserving —detail's key set and value types are unchanged, soVALIDATE_SCHEMA_VERSIONstays 1.A deliberate scope addition: the multiplexer's entry-point scan is now ordered by (name, distribution), as the adapter and profile scans already were, so which of two same-named distributions is recorded first is a fact about the packages rather than about
sys.path. -
A run launch whose adapter class rejects a bootstrap keyword now aborts on a clean
error:line instead of a bare traceback (#569). The construct call converted only the failures an adapter family declares, and a signature mismatch is refused by the interpreter rather than by the family — so an out-of-tree class whose__init__does not accept a keyword the bootstrap passes escaped uncaught. The abort now names the profile, the adapter kind and the rejected keyword; the exit code is unchanged at 1. Deliberately unchanged: aTypeErrorraised from inside a working__init__still surfaces as itself, because that is a bug in that package rather than a declared failure, and relabelling it as a signature mismatch would bury it. This is message quality only — an error escaping here already unwound the composition it had started. -
A modal dialog on a terminal narrower than its declared width now shrinks to fit instead of being clipped, so its action buttons stay reachable (#281). The docked button row is right-aligned, so the buttons were exactly what fell off the right edge — the horizontal twin of the vertical defect #275/#280 fixed.
EscalationModalneeded 87 columns, so a standard 80-column terminal already clipped it outright;DecisionModalneeded 83 and the bounded confirms 61. Below 60 columns those buttons additionally render smaller and tighter, because clamping the dialog alone still leaves a three-button row demanding 54 columns inside a content region narrower than that.On the other axis, below 20 rows a dialog now renders in a compact layout — collapsed padding, no title margin, a one-row button row — rather than clipping its docked buttons and safety warnings.
docs/tui-guide.mdaccordingly records the terminal sizes these dialogs were measured at — 39 columns × 9 rows for a dialog's own chrome — stated as sizes measured to be sufficient rather than as a supported minimum, and scoped to the dialogs, not the dashboard behind them.EscalationModalsets that figure and it is width-dependent, its docked warning wrapping to 9 rows at 39 columns but 6 at 80. Titles, headers and paths dock outside the scrolling body, so a long enough one wraps and costs rows the body cannot give back: a ~300-character deferred-work heading clips the close button, the validate viewer grows with its document's spec folder (#629), and the spec viewer'scopy pathbutton plus its action verbs simply do not fit 39 columns (#628). Nothing bounds that caller-supplied text, so no fixed size is a minimum these dialogs will always meet; a standard 80 × 24 terminal absorbed every measured example and the guide quotes it on those terms. At 60 columns and 20 rows and above the full chrome renders exactly as it does today. -
A deferred-work bundle whose name or key ends in a newline is now rejected instead of becoming a directory (#330). Both sweep regexes anchored with
$, which in Python also matches just before a single trailing newline, and every call site uses.match(). So a bundle namedfix-it\nvalidated clean and was used unsanitized as both therun_dir/bundles/<name>/path segment — invalid on native Windows — and thedw-<name>story key, while a key ofdw-foo\nsilently rebuilt a degraded bundle's intent under the different directoryfoo. Both patterns now anchor with\Z. Only a name or key ending in exactly one bare LF flips to rejected;\r\n,\n\n, a bare\r, a trailing space and any interior newline already were. A cached triage carrying such a name now re-runs instead of crashing, and a pre-fix run-state task keyed that way becomes inert rather than re-driven under a silently different name. Sweep-scoped: a bundle name is still not guaranteed safe as a path segment.
0.10.0 — 2026-08-14
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 ashooks.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's
deferred: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 confirmworks 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'soperator_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 todoneand 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 on
attempt-preserve/*, but the ref only ever reachedjournal.jsonl, leaving you to hunt withgit log --all; the notice now names the ref and thegit merge --ff-onlythat restores it. Newpreserve_refon each task is projected intostatusand--json(additive; schema stays 1). -
Stories can close deferred-work entries (#234).
closes_deferred: [DW-5, DW-6]on astories.yamlentry or in a spec's frontmatter flips each named entry tostatus: donewith aresolution: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 exhaust
max_dev_attemptsand defer a story untouched. Adapters classify it from per-profileenv_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 installedbmad-loopconsole script. -
{story_title}inscm.commit_message_template(#475). The placeholder renders the spec'stitle: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 name
bmad-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).genericcompletes 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 untilsession_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 tokennone. 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 bites
scm.rollback_on_failure = trueand in-worktree retries. -
A review that revokes the sprint sign-off escalates (#334).
sprint-statusis advanced todoneat 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.returncodenow 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 thedocs/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 emptiedpluginskey 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.mdandCONTRIBUTING.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'strunk-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 mentionedpyrightor 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 carriesuv run pyright, the## [Unreleased]CHANGELOG contract, the WindowsPYTHONUTF8=1requirement 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 gained
bmad-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's
intent.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 baselineresumecompares 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 samedelete/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, soCLEANUP_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 once
cleanremoved 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 forRIDand 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 andxall 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 operator
kill-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 afallbackselection 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.platformandwin32 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.tomlandprofiles/*.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 newhooks.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, unattributed
sprint-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_filesandskill_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, an
inf, 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's
seed_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's
closes_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-uncommittedandstory-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's
closes_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 only
openentries 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-missinginstead 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 on
Unknown 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 writesHALT: …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, andskills.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--legendfile. -
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-autoaccepted — sovalidate,run,sweepandresumepass on either era with nopolicy.tomledit.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 bygit 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 stalled
git 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 every
except 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 an
ENOSPCorEIOpartway 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 proseblockedorawaiting-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 nested
status:was rewritten into corruption. The edit is now re-parsed withyaml.safe_loadas an oracle and kept only if the block still parses as a mapping, itsstatusis the target, and every other key is unchanged. -
A status no line edit can safely move now raises instead of reporting success (#335).
Falsenow 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 every
tryin the commit phase — so an escape skipped the park notification, thepost_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: no
story-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). AnEMFILEorENOMEMescaped 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 aGitErrortakes, 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 two
status: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, astatusoutsideopen/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 onbmad-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-detectedfindings; 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_clientwidens 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 vacuousTruewhere 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
@Nresolves 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 scored
completed: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 askills.customize-unreadablewarning, and a layer gated by a run-time condition isskills.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 wasnotify-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 to
status: 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 carry
synthesized_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 its
in-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 launchstatus: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.
0.9.1 — 2026-08-02
Compatibility hotfix for the BMad Method's bmad-dev-auto → bmad-build-auto rename
(BMAD-METHOD#2651, first shipped in bmad-method 6.10.1-next.33) and the two upstream changes
that rode the same window. Both skill eras are supported; the rename itself needs no
policy.toml edit.
Fixed
-
The dev primitive is resolved on disk, so the upstream rename no longer breaks a project (#405).
bmad-build-autois preferred and a marker-completebmad-dev-autoaccepted, sovalidate/run/sweep/resumepass on either era and report which one resolved. A newskills.base-shimcheck refuses the forwarding shim, which HALTs an unattended session when a legacy_bmad/custom/bmad-dev-auto*.tomlsits beside it.[dev] skillstaysbmad-dev-auto. -
Every session prompt spells the resolved primitive (#405). Dev, review, repair, restore, stories dispatch and all three sweep bundle legs hardcoded
/bmad-dev-auto, which post-rename dispatches the shim. The name is now resolved per skill tree, so a run mixing.claude/skillsand.agents/skillsgets the right one per role, and the fallback marker matches both prefixes. -
--dry-runsays when its preview is not runnable (#405). Dry runs returned before their own skill preflight, so a broken install still got a plausible-looking schedule. Preflight failures now print to stderr under a "NOT runnable as-is" banner; exit code stays 0 and stdout is untouched, since rc 0 means "the preview rendered", not "ready to run". -
The skills preflight no longer gates a triage-only CLI's skill tree (#405). Every
skills.*check asks a dev-primitive question, yet the tree list came from all three adapter roles — so a[adapter.triage]ofgeminidemanded the whole BMad Method module in.agents/skills. It is now scoped to dev and review; single-CLI and dev/review-split projects are unaffected. -
A worktree that could not be given its upstream skills now pauses instead of stalling (#405). The containment guard skips a skill tree symlinked to a shared machine-wide install while the run-start preflight stats through that symlink and passes, so every isolated session stalled on
Unknown command. Undelivered skills are journaledworktree-seed-skippedand the engine escalates before dispatch. The seed also merges per file, and checks each file for containment — a behaviour change. -
A renderer stub that cannot compose a prompt fails preflight (#405). Three new checks block
validate/run/sweep/resumewhen the resolvedSKILL.mdis the renderer stub (BMAD-METHOD#2601):skills.dev-rendererfor an incomplete script unit,skills.dev-renderer-configfor an absent_bmad/config.toml(the renderer's only required config layer), andskills.dev-renderer-sourcesfor a missingworkflow.mdor an unloadable[[bmad-snapshot:…]]token. -
A stale customization override is reported, not obeyed (#405).
skills.customize-legacywarns when an override still sits at_bmad/custom/bmad-dev-auto[.user].toml, where it no longer applies — the session runs, unstyled. -
Worktree isolation carries the
_bmad/config surface (#405). The renderer-era primitive takes the worktree as its project root and hard-fails when that root has no_bmad/— there is no walk-up — so on a project that gitignores it (most do) every isolated session HALTed with nothing written. Provisioning now merge-copies_bmad/per file, and a short seed pauses the run when the resolved primitive is a renderer stub, naming what is missing. -
The renderer's
_bmad/render/output stays out of story commits (#405, #409). Outside a git repo the check stays quiet rather than fabricating an ok (#409). It is never seeded into a worktree and is git-excluded inside one, andinitnow gitignores it — the only protection under the defaultisolation = "none". Neither shield helps a path already tracked, so a newgit.render-trackedwarning names the one-timegit rm -r --cached _bmad/render. -
Worktree provisioning survives a filesystem it cannot fully read (#405). The
_bmad/seed and its detector both usedrglob, which does not descend a symlinked sub-directory, so a symlinked_bmad/scripts/libwas under-seeded and reported complete; both now share the skill seed's walk. Per-file faults in the walk are skips the gate names, not an exception out of provisioning; the eagerworktree_seed, plugin and hook-config copies still raise. -
A seed entry the worktree never got is now reported (#405). The seed loops dropped an entry with a bare
continuewhen the containment guard refused it, so a config the repo carries as a symlink out of itself delivered nothing and said nothing. Drops are journaled asworktree-seed-dropped, andworktree-openednow fires at mount rather than after the gates. -
Seeding never writes through a symlink (#405). Both seed loops probed and copied against the resolved destination, and
Path.resolve()is non-strict — so a dangling link reported its slot free and the copy landed at the link's target, a pathgit add -Awould stage into the story branch.git worktree addproduces exactly that state for a tracked symlink whose target is untracked, so it arrives on a project's first story. -
A hook config is no longer its own alibi (#405). The drop report probed the destination for existence, but the per-CLI hook step writes
profile.hooks.config_pathafter both seed loops, so a dropped config was answered for by the hook's own bytes — and for gemini, copilot and antigravity that path is the profile's only default seed. Those rels are now asked whether the source escapes the repo, or the destination is a symlink or escapes the worktree. -
isolation = "worktree"is refused whenrepo_rootis overridden (#405). A project whose_bmad/bmm/config.yamlsetsrepo_root— the documented monorepo knob — decouples the git root from the project dir. Provisioning seeds every surface offrepo_rootwhileinit,validateand the run preflight write and probe them underproject, so the preflight approved a surface the isolated run never received and every story ended as a result-less Stop. -
The
repo_rootrefusal covers every entry point (#405).validatereportspolicy.isolation-repo-root, andrun,sweep,resume,resolveand the child sweep all refuse — drop the override, or setisolation = "none". The child raises rather than declining quietly, so a mid-run flip is journaledsweep-auto-failed. Plumbingprojectthrough provisioning stays open as #414 and #443. -
worktree_cleanandpath_trackedno longer read their answer out of git's stderr (#405).ls-filesandstatusexit 0 while still writing to stderr, and that chatter was indistinguishable from an index entry or a porcelain line — sogit.render-trackednamed an uncommitted path and a clean checkout read dirty. Other callers still merge, which is #442. -
An undecodable
policy.tomlorconfig.yamlis reported, not a traceback (#405).UnicodeDecodeErroris aValueError, not anOSError, so it escaped everyexcept (PolicyError, OSError)handler — the TUI could not open and the CLI died at startup on a raw codec message naming no file. Both loaders now raise typed errors;validatenames the file. -
Deferred review findings are harvested out of the spec's frontmatter (#405). BMAD-METHOD#2640 moved
defer-triaged findings fromdeferred-work.mdinto the spec's owndeferred:list, silently starving the sweep pipeline. A successful dev, repair or review session now files each into the ledger with itslocationandseverity(spec-deferrals-harvested), deduped on a fingerprint of its summary and location; the frontmatter is never rewritten, so a revert is lossless. The review prompt no longer files the finding itself, which double-filed it. -
A transient harvest read cannot silently drop recorded findings (#405). An unreadable, missing, undecodable or temporarily malformed spec at the harvest step now follows the existing bounded retry paths instead of letting a later verifier accept and commit without the ledger repair. A persistent fault defers with the artifacts preserved, or re-escalates a CRITICAL.
-
Post-session ledger work keeps its proof attribution across crash replay (#405). The completed-session checkpoint is refreshed after hooks return, so a retained retry cannot hide a hook-authored ledger repair behind an earlier harvest's engine-written exclusion. A pre-feature checkpoint with no ledger digest reconstructs attribution from its Git baseline, so a ledger-only session still resumes while the new harvest stays excluded from proof.
-
A harvested deferral is reverted when its attempt rolls back (#405). The harvest runs above the artifact gate, so a session that finalized its spec and then failed a non-fixable check left an entry describing discarded code the reset cannot remove — the ledger sits under a
keep-shielded artifacts folder. The dev phase now snapshots it and restores around the rollback, persisting the snapshot with the attempt and never unlinking a ledger git tracks. -
That restore spans the whole retry chain (#405). On the
rollback_on_failure = offdefault it reverts every attempt's ledger writes, not just the last — matching thegit reset --hard <baseline_commit>the pause notice tells the operator to run. Reaching the restore unarmed still recordsledger-snapshot-missing. -
The pre-harvest snapshot is spent by a stop-and-wait pause, and scoped to the retry chain (#405). Under the default
rollback_on_failure = offthe operator may append to the ledger before typingresume, but the armed snapshot survived that wait and the resume wrote stale bytes over their edit; the pause leg now disarms as it restores. A fixable retry neither re-arms nor spends it, so the revert reaches as far as the reset does. Not closed:_finish_inflight's restart arm resets to the same baseline with no ledger restore at all. -
A harvest is reverted even when the ledger lives outside the repo (#405).
implementation_artifactsmay be configured out of tree, and the restore classified any such ledger as git's — backwards, sincereset --hardcannot reach outside the workspace, so every revert was a no-op and the entry outlived the code it described. It now reads as "not git's". -
The harvest's own ledger write is no longer the session's proof of work, and the session's own edit still is (#405). The dev artifact gate deliberately does not exclude the ledger — a story whose whole scope is ledger reconciliation has to count as real work — so it could not tell the orchestrator's write from the session's, and a session that changed no code but recorded one finding proceeded to done on the engine's line. The gate now excludes the harvest's own attempts, and stands down again when the ledger had already moved off the dev phase's baseline.
-
Both of those answers span a fixable retry (#405). A fixable retry keeps the harvest's ledger line with its tree by design, so the repair session met a stale baseline: an attempt that merely reverted the offending change passed on the orchestrator's own write, and a stalled attempt deferred with an empty record of the harvest's intent. Both records now survive the continuation, and the ledger baseline re-bases: forward onto the kept tree, back onto the restored snapshot on a rollback. (A gitignored ledger is invisible to the gate either way.)
-
A sweep bundle's ledger closes are withheld until its attempt is accepted (#405). The orchestrator marked a bundle's ids
doneabove the artifact gate, so a discarded attempt left the ledger claiming its work resolved, andopen_idsre-bundles only open entries. The close now runs below the gate, for a PROCEED decision only. One deliberate consequence: a bundle session that changed no code now fails the gate rather than passing on the orchestrator's own bookkeeping. -
A review-leg defer takes those closes back (#405). A defer after acceptance — every route, the stock
trigger = "recommended"included — re-opens the ids it closed itself, since_deferwrites its post-close snapshot over the reset that reverted them. -
An isolated unit's ledger writes reach the main checkout — findings and bundle closes alike (#405). A defer drops the unit worktree unmerged, and a landing unit's ledger edit rides the branch only when the ledger is tracked:
finalize_commitstages withgit add -A, which skips a gitignored path in silence. Both legs now re-file what the harvest intended and re-apply what the close intended, the DONE leg after the merge so entries dedup;mark_doneis idempotent and a DEFER carries findings only. An unseeded gitignored ledger still defers rather than lands. -
A carry lost to a crash mid-landing replays on resume (#405).
Phase.DONEis persisted before the merge, the merge removes the worktree, and only then does the carry fire — so a host death there skipped it and resume never looked again. Resume now re-runs an unlatched carry once, behind a persisted latch and a durableunit-mergedrecord; the merge writes its intent before invoking git, so an interrupted merge is re-run rather than assumed to have landed. -
Tracked ledger carry failures stop and remain replayable (#405). A rejected
git add,statusorcommitused to be journaled and swallowed, letting an isolated unit finish with a dirty or staged main ledger. A committable carry now persists its commit intent and raises, and resume retries it. Gitignored and external ledgers keep their advisory degrade behavior. -
An artifacts dir whose name holds
[or]no longer misdirects those two paths (#405, #423). Git reads a positional operand as a pathspec, so such a path was a glob that also matched its neighbours: the harvest revert's tracked-probe answered off a neighbour and skipped its unlink, and the carry's commit swept a neighbour's edit in. Both now name the path literally. -
Three small contracts brought back into line (#405). A ledger entry filed with no file:line carries
location: n/a, the valuedeferred-work-format.mdhas always specified — entries already on disk without the line stay valid, so read an absentlocation:asn/a. A plugin workflow's completion marker resolves on its own role's skill tree, not always the dev one. Anddiagnosepseudonymizes thespecjournal field, which was shipping the feature name verbatim.
0.9.0 — 2026-07-21
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'stmux*, 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 existingbmad-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_bytesas 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, withdry_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 statusunknown, 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 newover_budgetsession status, which rides the ordinary retry→defer routing. Defaults towarn: 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 headlessopencode 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 thebmad-loop-<run-id>/bmad-loop-ctlsession names carry over unchanged. It registers forwin32and is the platform default there, selected automatically when thepsmuxandpwshbinaries 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-timeregister_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 awarning: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 shippedunity_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. Newbmad-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 theTerminalMultiplexerseam 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 tobmad-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-autonow 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-worksweepbundles 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 acustomize.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). Themux.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_versionof 1 alongside its existingversionkey, which still holds the probed CLI's--versionoutput. 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_flaghelpers;status --jsonuses them (output byte-identical) and the duplicated token-total math folded intorun_token_totals. All four--jsoncommands share the contract (#195);--jsonadoption 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 saymultiplexer new-session/new-window failedandmultiplexer backend unavailable (binary not on PATH), the TUI notifiesmultiplexer backend unavailable — launch/attach disabledandlaunched (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.tomlonce (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 %Nfrom 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/customcopies 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 aworktree-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 moment
diagnose --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 defaultensure_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_jsonserializes 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 thepolicyblock 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 undercache_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 namesensitive[<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 by
bmad-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: with
rollback_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) makesgit worktree removefail with ENOTEMPTY, git still drops its admin entry, so theforce=Trueretry 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 tormtree+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 aworktree-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 translates
subprocess.TimeoutExpiredintoGitError, 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 totasks/<id>/session-lifecycle.jsonl; each wait tick tops up a throttledtasks/<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 (statusabortedwhen the outcome is unknowable), carryingfired_at/teardown_s/expired_clock. (3) Teardown is now a verified kill escalation:terminate → wait → force_kill, wherelimits.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) andopencode-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 nullsession_id, andcwdwas never populated (agy sends nocwd, only aworkspacePathslist). 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 onlystep_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 insettings.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 addcollided ("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 until
session_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 transientin-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 todoneand 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 stuckin-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 before
finalize_commitran and the DONE save stampedcommit_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 at
DEV_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 agit 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 XTMODKEYS
CSI > 4 ; ? m; the marker byte sat inside the params, so the private-marker strip filter missed it and pyte 0.8.2 raised aTypeErrorthat 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 transient
OSError(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.
SweepEnginerecovered 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, sobmad-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. ANonesnapshot (a pre-upgrade run) still counts every untracked file — deliberately the opposite ofattempt_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-autostampsbaseline_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 stampsbaseline_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_namebuiltbmad-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 atgit worktree addwith "is not a valid branch name". Both segments now go through a newplatform_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). Agit 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-atomiccopy2there — 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 recordssession-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 run
CRASHEDand 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 underdirectories_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)
0.8.1 — 2026-07-05
Added
- Attempt-preserve recovery refs are now bounded. With
scm.rollback_on_failureon, auto-rollback parks a failed attempt's committed work underattempt-preserve/*and its dirty worktree snapshot underattempt-preserve-dirty/*; nothing pruned them, so they grew unbounded. Run start now keeps only the newestscm.preserve_keep(default 20,0= never prune) per family by committer date and best-effort-deletes the tail — a stuck ref never wedges the ones behind it, and prune failures are journaled but never block the run. (#50 #54, closes #32 #49)
Changed
- The adapter shell-dialect seam is now an explicit, documented contract.
new_window/new_parked_windowfactor their shell-dialect fragments into overridable hooks (POSIX output stays byte-identical), and thecommandparameter's semantics are pinned in the ABC docstring (shlex-joined argv; operator handling is backend-defined). Relevant only to authors porting the adapter to a non-POSIX backend. (#47 #60)
Fixed
- A session that finished its work but crashed before the run recorded it no longer loses that work
to a restart-and-rollback. The engine now records a completed dev/review session behind a
durability barrier before running post-session hooks (usage is folded in afterward as best-effort
metadata); on resume it consumes that durably-recorded result straight into verify/decide instead of
restarting the attempt from baseline. A raw
KeyboardInterruptnow records a controlled stop, and replay preserves the attempt/cycle/baseline. (#62, closes #57) - Several narrower resume-replay edges opened by that durability work are closed. A host death in
the post-session window of the last allowed review cycle no longer drops a recorded clean review to
a defer; a reconcile early-return no longer persists a pre-reconcile dict that could silently skip a
recommended follow-up review; and triage/sweep and labeled plugin-workflow sessions no longer persist
large, never-consumed result payloads into
state.json. (#63) - A dev session is now completed only on a real Stop or window death, never on a terminal artifact
glimpsed while the agent window is still alive. The idle-tick and grace-expiry shortcuts could
return "completed" mid-turn and let the run's cleanup kill a working agent, handing the engine
half-written state. Both shortcuts are removed, liveness is re-probed immediately before the
grace-expiry stall verdict, and a re-driven spec has its stale
## Auto Run Resultsection stripped on both re-arm paths so a resolved re-drive can't be misread as its own terminal result. Result scans are now fence-aware, so an## Auto Run Resultheading quoted inside a fenced code block is never mistaken for a real section (nor destructively stripped). (#53, closes #48 #52) - Long journal fields in the TUI now wrap with a hanging indent instead of spilling back under the timestamp and kind columns. Each row renders as a fixed-width grid whose fields cell folds within its own column.
0.8.0 — 2026-07-03
Added
- New
pre_commit_gateplugin workflow-injection stage. Gate workflows can bind topre_commit_gate, which fires unconditionally just before every commit — on the review-converged, review-skipped, and review-budget-rescue paths alike — while the phase can still legally defer. TEA's trace/nfr/review gates rebind to it, fixing blocking gates that were previously inert whenever a dev session recommended no review follow-up (soon_pre_commitfail-opened on the missing artifacts).
Changed
- BREAKING: the project is renamed
bmad-auto→bmad-loop. The distribution, console script, and CLI are nowbmad-loop; the Python package isbmad_loop(wasautomator); the BMAD module code and marketplace plugin arebmad-loop(wasbauto); per-project state moves from.automator/to.bmad-loop/. The GitHub repo is now bmad-code-org/bmad-loop — old web and git URLs redirect. Clean break: no compatibility shims. - BREAKING: renamed public identifiers. Env vars
BMAD_AUTO_*→BMAD_LOOP_*; plugin entry-point groupbmad_auto.plugins→bmad_loop.plugins; hook relaysbmad_auto_hook.py/bmad_auto_probe_hook.py→bmad_loop_hook.py/bmad_loop_probe_hook.py; skills/bmad-auto-{setup,sweep,resolve}→/bmad-loop-{setup,sweep,resolve}; tmux session/window prefixesbmad-auto-*→bmad-loop-*; worktree branchesautomator/<run-id>→bmad-loop/<run-id>; TUI classBmadAutoApp→BmadLoopApp. Custom plugins, CLI profiles, and policy files that reference any of these must be updated.
Fixed
- A workflow session that finishes its work but never writes a completion marker no longer
livelocks the run. Each result-less Stop used to refill the stall-nudge budget, re-nudging a
responsive-but-signal-less session until
session_timeout_min. The engine now appends an explicit completion contract (absolute marker path + frontmatter shape) to every workflow-session prompt, and a newlimits.workflow_stall_nudges_cap(default 3) caps the total nudges a workflow session may receive — degrading a still-missing marker to "stalled" in ~30 min instead of hours. Dev/review session nudging is unchanged. - On Windows, a live engine whose process identity can't be read now shows
UNKNOWNinstead of a falseINTERRUPTED. psutil raisesERROR_ACCESS_DENIEDfor a process in another session or elevation, which the identity-aware liveness read had surfaced as dead — mislabeling running runs and weakening the resume/delete guards. Liveness is now tri-state (alive/dead/unknown) and biased away from false-dead;resume,resolve,delete,archive, and cleanup all surface and warn onunknownwithout ever letting an unverifiable pid block cleanup forever, andresolvegains--forceto override a squatted-pid block. - A review session that appends to the deferred-work ledger no longer leaves a sweep bundle
unclosed. The bundle ledger is reclosed after review (journaled distinctly as
sweep-bundle-reclosed), and the review prompt now states the ledger is append-only for sessions. - Worktree git-exclude patterns now anchor correctly on native Windows.
install.pynormalizes backslashes in the per-worktree exclude paths so the ignore rules match (a no-op on POSIX).
Migration
- Reinstall the tool under its new name — uv can't rename a package in place:
uv tool uninstall bmad-auto, thenuv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git". - Re-run
/bmad-loop-setup(orbmad-loop initdirectly).initmigrates a project in place: it strips the old.automator/Stop hook from each CLI's settings, removes thebmad-auto-*skill dirs, and carries.automator/policy.tomlover to.bmad-loop/policy.toml. Setup folds the oldbautoconfig intobmad-loopand clears the leftoverbautoconfig section, staleBMAD Automator Skillshelp rows, and the_bmad/bauto/installer dir. - Legacy
.automator/is left in place (runs, archives, profiles, plugins) and can be deleted or hand-moved once the migration is confirmed; stale.automator/*gitignore lines are left untouched.
0.7.12 — 2026-07-01
Added
- The TUI dashboard now shows the cost-proportional weighted token total, with the raw total in a new
column. The
tokenscolumn and the run-header summary discount cache-read tokens by the run'scache_read_weight— the same weighting the per-story budget enforces — so the headline number tracks spend rather than context re-reads; the previous unweighted total moves to a newrawcolumn.
Fixed
- The dashboard no longer crashes when a background poll lands as the screen is torn down or
switched away. A poll worker delivers its refresh on the UI thread; if that arrived just as the
app quit or another screen opened, the query for the run table raised
NoMatches. The apply now drops stale refreshes for a screen that is no longer running (while still updating one merely backgrounded under a modal). - A failed attempt's work is preserved before an auto-rollback hard reset instead of being silently
discarded. With
scm.rollback_on_failureon (or on a resolved re-drive), a deferred or stopped attempt's commits above baseline are now parked under anattempt-preserve/<run_id>-<head8>branch, and its uncommitted working-tree diff — tracked edits and run-created untracked files alike — underrefs/attempt-preserve-dirty/; both are recoverable by name and survive gc. A plain rollback that cannot create the ref refuses to reset and pauses for manual recovery rather than destroying work. The uncommitted snapshot is scoped to this run's own changes (never a pre-existing untracked file), commits under a synthetic identity so it works with no git user configured, and is keyed per retry so repeated rollbacks against the same baseline no longer overwrite each other's recovery ref. - Process liveness is now identity-aware, so a reused PID no longer reads as a live run. A recycled pid (common on Windows) used to register as a false "alive" — blocking resume of a dead run, stranding worktree reclaim, leaking sessions, and showing dead runs as RUNNING. The pid file now carries a process-identity token that resume, stop, and the TUI verify against; on win32 the engine also ignores console SIGINT/SIGBREAK during a run so a ConPTY Ctrl+C broadcast can't kill it.
- A story from a resolved escalation that still can't finish now re-escalates instead of being
silently deferred. When a human-resolved CRITICAL
blockedescalation was re-driven and the re-drive couldn't reachstatus: done(e.g. the environment was still broken), the story used to exhaust its dev/review budget and plateau-defer — filing an unresolved blocker as deferred work and rolling back the implemented code. Whileresolved_redriveis latched, budget exhaustion now re-escalates (pauses for the human) instead of deferring, and the attempt's tree is preserved. - A resumed
--epic Nrun stays scoped to its epic and no longer declares the epic "done" while stories remain.resumerebuilt the engine without the run's--epic/--story/--max-stories, so a scoped run silently widened to every epic; with strict file-order story selection, deferring or finishing a story in an epic placed out of numeric order in the sprint board (e.g. one appended last) bounced selection to an earlier-in-file epic and fired a spurious "epic N complete" boundary, stranding the epic's remaining stories. The selector and cap are now persisted and restored on resume, and story selection exhausts the current epic before advancing — so an epic boundary fires only when that epic has no actionable stories left. Document-order epic execution is unchanged.
0.7.11 — 2026-06-30
Fixed
- A finalized story the review just won't stop recommending a follow-up for is now committed, not
rolled back, when the review budget runs out. Exhausting
limits.max_review_cyclespreviously always deferred + reverted — discarding completed, review-passing work (frontmatterstatus: done, verify green) whose only "failure" was a never-clearingfollowup_review_recommended. The orchestrator now commits that work and re-files the lingering recommendation as a fresh open deferred-work entry; a story that itself came from such an entry is committed without re-filing, so a second non-convergence reaches a human instead of looping across sweeps. Worktree-isolation runs were unaffected — a deferred unit already keeps its worktree.
0.7.10 — 2026-06-29
Fixed
- Completed work left at the transient
in-reviewfrontmatter is no longer falsely deferred and rolled back. Abmad-dev-autosession that dies in its step-04 Finalize tail can leave the spec frontmatter at the transientin-reviewmarker while the## Auto Run Resultprose already saysStatus: done— the same stale-frontmatter gate bug as 0.7.8 with a different value. The 0.7.8 reconcile skippedin-reviewto protect the legacybmad-auto-devreview-handoff, but that fork is retired andin-reviewis now only ever a transient marker, so it is reconciled todonebefore the gates run. Every deterministic gate still runs afterward, and afollowup_review_recommended: truespec still triggers the follow-up review pass. Closes a re-sweep loop that re-ran and discarded the same completed bundles (~47M tokens/cycle).
0.7.9 — 2026-06-29
Fixed
- The multiplexer seam no longer leaks raw subprocess timeouts. Every contract method now raises
MultiplexerError/TmuxErroror returns its documented sentinel instead of letting a 30 s tmux hang escape as a rawsubprocess.TimeoutExpired. - A transient tmux hang no longer crashes a run or mis-reads a working session as dead. The
wait-loop tolerates an unknowable liveness probe — a persistent hang degrades to an honest
timeout, never a falsecrashed. - An unexpected engine exception is now recorded instead of being lost to the parked control
window. The orchestrator writes a
run-crashjournal line + acrash.txttraceback, sets aCRASHEDrun status, and tears down the orphaned agent session — rather than dying with a traceback printed only to the pruned control pane.
0.7.8 — 2026-06-29
Fixed
- Completed
bmad-dev-autowork is no longer falsely deferred when the skill leaves the spec frontmatterstatusstale. The skill can finalize a run in its## Auto Run Resultprose (Status: done) yet leave the YAML frontmatter at the template defaultdraft; since every gate reads frontmatter, the sprint/ledger sync no-op'd and the story or sweep bundle deferred — losing tested work on rollback. The orchestrator now reconciles the frontmatter to the success status from the terminal prose before the gates run (journaledspec-status-reconciled). It reconciles only adoneoutcome from a non-terminal status, and every deterministic gate (worktree diff, dw-ids, verify commands, ledger) still runs — so the bookkeeping is repaired without trusting prose to pass a gate.
0.7.7 — 2026-06-28
Fixed
- Spec-status gates are now case- and whitespace-insensitive. A hand-edited spec whose
frontmatter carried a stray-cased
Done/In-Reviewsilently failed the dev/review gate and the story never advanced; every spec-frontmatter status read now normalizes through a singleverify.status_ofhelper. The well-formed lowercase path is unchanged. Also fixes the manual-rollback notice, which rendered an invalidgit reset --hard the run's baseline commitwhen no baseline was recorded — it now shows a<baseline_commit>placeholder. - Project-relative path guards reject
..traversal and OS-foreign absolute paths. A CLI profile or plugin manifest could declare aconfig_path/skill_tree/seed_files/module path that climbed out of the project with../— or, on Windows, a POSIX-absolute/etc/...thatPath.is_absolute()failed to flag — and slip past the "must be project-relative" check. The guards now reject both, on every platform. - Persisted relative paths serialize with forward slashes. A worktree run's
spec_fileand the resolve context'sresolution_pathwere written with the host separator, so astate.jsonor context file produced on Windows read back with backslashes. Both now persist viaas_posix()for a single cross-OS contract (a no-op on POSIX). - The TUI no longer shows a stale run after a same-size state rewrite. The dashboard's
stat-gated cache keyed on
(mtime_ns, size), so an atomicstate.jsonrewrite of identical size within one coarse mtime tick (e.g. WSL2 drvfs) could be served stale. The engine rewrites atomically onto a fresh inode, so the cache signature now includesst_ino. - A dev session is no longer mis-stalled while it is actively working or legitimately waiting.
Building on
dev_stall_grace_s(0.7.5), the idle-grace window now measures genuine inactivity rather than time-since-last-Stop: any growth of the session's pane log (a long productive turn, a streaming subagent) re-arms it, so a session that has finished implementation and is mid-review is no longer killed and rolled back. And because bmad-auto cannot re-invoke a turn that ended to await a background process, the grace window no longer dead-ends in a stall — on real silence the orchestrator wakes the session with up tolimits.dev_stall_nudges(new, default 2) nudges before giving up; a genuine Stop restores the budget, so a slow-but-cooperative session waits up tosession_timeout_minwhile a truly unresponsive one still stalls.0restores stall-on-grace-expiry.
0.7.6 — 2026-06-28
Changed
- The OS is now abstracted behind a registry of seams, so a non-tmux/native-Windows port is new
files plus one registration line each — no core edits. The terminal multiplexer is selected
through a registry (
register_multiplexer, bysys.platformwith aBMAD_AUTO_MUX_BACKENDoverride) rather than hardcoded inget_multiplexer(), and the tmux backend split into a reusableBaseTmuxBackendextension point — every tmux invocation funnels through one overridable_run()primitive — withTmuxMultiplexeras a thin POSIX leaf, so a tmux-family backend (an eventual "psmux") overrides only the spawn primitive and the few divergent methods. - Process-lifecycle primitives moved behind a
ProcessHostseam. Politely-stop / force-kill / liveness / PID-reuse-identity now route throughget_process_host()(registered like the multiplexer, with aBMAD_AUTO_PROCESS_HOSToverride); aWindowsProcessHostships ready to register. Hook registration no longer hardcodespython3— it takes the interpreter prefix fromProcessHost.hook_interpreter()(POSIXpython3; Windowsuv run --no-project python), andbmad-auto validateruns a platform preflight that reports the selected backend's readiness and names the process host. Behavior on Linux/macOS/WSL is unchanged. - The bundled Unity plugin's teardown delegates pid lifecycle to
ProcessHostand its helper scripts run under the orchestrator's own interpreter. Plugin helper scripts are spawned viasys.executable(not a PATH-resolvedpython3), so a bundled script may importautomatorseams;unity_teardown.pynow reaps leaked Editor/MCP processes throughget_process_host()instead of re-implementing kill/liveness, gaining Windows behavior for free.
Added
- A consolidated porting guide maps the four OS seams (terminal multiplexer, process lifecycle, hook interpreter, validate preflight), their registries and test-override env vars, and exactly what a native-Windows port costs end to end. The adapter-/plugin-authoring guides, ROADMAP, README, and FEATURES are updated to the post-registry world.
0.7.5 — 2026-06-28
Added
- Select and copy text from the TUI Log and Attention panes. Click-drag highlights and
ctrl+ccopies (those panes are now selectableRichLogs with a workingget_selection);ycopies the whole active pane in one keystroke. The other panes are interactive widgets — hold your terminal's bypass modifier (Shift on most Linux terminals, Option on iTerm) to use its native selection. Copy rides OSC 52, so under tmux it needsset -g set-clipboard onto reach the system clipboard. bmad-auto diagnoseemits a sanitized diagnostic dump of a run/sweep so a user can hand maintainers what's needed to debug a run without shipping any code, spec/story content, prompts, transcripts, file paths, or PII. It derives the diagnostic shape — phase/token/session histograms, escalation counts, adapter/model, env, run-dir file sizes — and routes every content-bearing value through the auditedsanitizechokepoint: identifiers (story keys, branches, SHAs) are pseudonymized to stable per-dump aliases so events still correlate, free text collapses to presence booleans, and the rendered output is re-scanned by a fail-closed leak self-check before writing. Defaults to the latest run;--all,--out,--json, and a local-only--legendare supported.
Changed
- The
sanitizechokepoint now redacts credential-shaped strings. Identifier-shaped secrets (ghp_/sk-/AKIA/xoxb-/JWTs and long high-entropy blobs) previously passed the slug gate verbatim; they are now<redacted:secret>, closing the same hole forprobe-adapter.
Fixed
- A dev session that ends its turn to await a long-running background process is no longer
mis-stalled. A
bmad-dev-autosession that yields to wait on a slow job (a Unity PlayMode run, a long test) and expects to be re-invoked on completion fired a result-less Stop — and since the generic dev adapter runs zero nudges, that was ruled stalled after only the 15s result grace, driving a retry that (withscm.rollback_on_failureoff) paused the whole sweep for a manual reset. A newlimits.dev_stall_grace_s(default 600s) opens an idle-grace window on a result-less dev Stop and re-arms it on each re-invocation, so only a genuinely idle gap with no terminal spec — or the session timeout — counts as a stall. Non-dev adapters keep zero grace and are unchanged.
0.7.4 — 2026-06-26
Fixed
- Deferred-work sweep no longer defers every bundle it just finished. After the migration to
the generic upstream
bmad-dev-autoprimitive, bundle dev sessions completed the work but were rejected byverify_dev_bundlewithresult.json dw_ids [] do not match the bundle's […], retried to budget, deferred, and rolled the work back — so a sweep could never close a bundled entry. The retired dev fork used to echo the dw ids; the generic skill doesn't. The orchestrator already owns the bundle→dw-id binding, so the cross-check now passes when the session claims no ids, and the run exportsBMAD_AUTO_DW_IDSso the synthesized result still carries them and the check stays live.
0.7.3 — 2026-06-26
Fixed
- The Log tab no longer shows only a single page for Claude runs. Claude Code's new
fullscreen TUI (an opt-in research preview) draws on the terminal's alternate screen and repaints
in place, so the pane capture the Log tab emulates collapsed to the final frame — while
line-oriented CLIs like codex still showed in full. The
claudeprofile now forces the classic inline/scrollback renderer (CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1), which overrides anytuisetting and keeps output in native scrollback so the whole session is reconstructable. As a safety net, the log view also detects an alternate-screen switch and flags such a capture as showing only the final frame, pointing at the agent's full JSONL transcript.
0.7.2 — 2026-06-26
Fixed
-
Worktree isolation no longer false-stalls a story that actually finished. Under
scm.isolation = worktreethebmad-dev-autosession runs with its cwd set to the worktree and leaves its terminal spec in the worktree's_bmad-output/implementation-artifacts, but the dev adapter searched the main checkout's dir (resolved once at startup). The completedstatus: donespec was never found, so the orchestrator misread the session as stalled, rolled the unit branch back to baseline, and re-ran the same story. The adapter now resolves the spec directory from the live session cwd; in-place runs and artifact dirs configured outside the project tree are unaffected. -
An uncommitted
policy.tomledit no longer vanishes on rollback.policy.tomlis tracked but lives inside the kept.automatordir, so a rollback'sgit reset --hardto baseline silently reverted operator edits — a freshly enabledscm.rollback_on_failurecould disappear before it ever took effect — and a lone policy edit could register as attempt dirtiness, trapping the manual-recovery loop. Rollback now restorespolicy.tomlfrom its on-disk content unconditionally — so a config change committed after the baseline on an otherwise-clean tree survives too, not only one that rode a non-emptygit stashsnapshot — and the dirty check always excludes it, regardless of the preserve set. -
Fixed a decision-toast notification race in the TUI test suite on Python 3.14. Test-only; no runtime change.
0.7.1 — 2026-06-25
Fixed
-
The Log tab no longer renders whole CLI sessions underlined. Modern CLIs emit an XTMODKEYS sequence (
CSI > 4 ; 2 m, "modifyOtherKeys") at startup that the pane emulator (pyte) misread as SGR 4 / underline-on — with no matching off present in a live capture — so every line came out underlined and hard to read. The log view now strips private-marker CSI sequences before emulation; genuine color, bold, and properly-closed underline styling is preserved. -
Resolving a CRITICAL escalation no longer loops on a manual-rollback prompt when the resolve edited the spec. 0.7.0 fixed the loop only for an already-clean tree, but the resolve workflow's whole job is to correct the frozen spec under the BMAD artifact folder (
_bmad-output/..., which is tracked). So on resume the orchestrator saw a dirty tree and — with the defaultscm.rollback_on_failure = false— paused for a manual reset; because the dirty check diffs against the frozenbaseline_commit, even committing the spec re-paused on the next resume, an endless loop. A resolved re-drive is human-initiated, so it now always auto-recovers regardless of the flag: the BMAD artifact folders are treated as orchestrator-owned — excluded from the dirty check and preserved through every reset of the re-drive (not just the resume-time cleanup) — so the spec correction survives while the failed attempt's source changes revert to baseline. This closes a latent sibling bug: withrollback_on_failure = truea later mid-re-drive retry/defer reset previously ran with no preserve set and reverted the just-corrected spec silently, looping the re-drive.scm.rollback_on_failurestill defaults OFF and now governs only unattended/stopped attempts; the manual-recovery notice (reached by stopped attempts only now) drops its resolved-cause wording. -
A failed artifact restore during rollback now surfaces instead of silently dropping the correction. When
safe_rollbackrestores the preserved BMAD folders from its pre-reset snapshot, a genuinegit checkoutfailure (corrupt snapshot, lock, IO) was swallowed alongside the benign empty-dir "pathspec did not match" case — so a corrected spec could vanish with no error and loop the re-drive. Real failures now raise; the empty-dir case stays tolerated.
0.7.0 — 2026-06-24
Changed
-
Retired the
bmad-auto-devfork; the orchestrator now drives the upstreambmad-dev-autoskill unmodified (bmad-code-org/BMAD-METHOD#2500, merged upstream) as its sole dev primitive. The skill is the inner autonomous coding session; everything automator-specific — escalation, sprint/ledger bookkeeping, repair-resume — stays in the orchestrator, which synthesizesresult.jsonfrom the spec the skill leaves on disk. There is no fork to keep in sync with upstream. -
Review is now a re-invocation of
bmad-dev-autoon the done spec, not a separate skill.bmad-dev-autoroutes astatus: donespec to a fresh step-04 review pass (BMAD-METHOD#2508), so the orchestrator's follow-up review just re-runs/bmad-dev-auto <done spec>in a fresh context.review.enabledstill gates whether that follow-up pass runs at all; the newreview.triggerknob (see Added) decides when it fires. The loop converges when a pass finishesdonewithout the skill settingfollowup_review_recommended, still bounded bylimits.max_review_cycles(default 3). -
The skill commits each iteration; the orchestrator squashes to one commit per story.
bmad-dev-autonow commits its own work at the end of a successful run (BMAD-METHOD#2506). At finalize the orchestrator collapses that chain plus its own sprint/ledger writes back onto the pre-dev baseline into a single commit carrying the configured message —pre_commit/post_commithooks andscm.commit_message_templatestay authoritative.
Added
-
Skill-recommended review (
review.trigger).bmad-dev-autoself-reviews inline and setsfollowup_review_recommendedon adonespec when its changes warrant an independent pass (BMAD-METHOD#2505). The orchestrator consumes it:review.trigger = "recommended"(new default) runs the follow-upbmad-dev-autoreview pass only when flagged;"always"keeps the old run-every-story behavior. Adjustable in the TUI andpolicy.toml. The follow-up loop stays bounded bylimits.max_review_cycles(default 3) — the oscillation guard — so a skill-recommended review can never loop indefinitely. -
Non-bundled-skill preflight.
bmad-auto validateand run/sweep/resume start verify thatbmad-dev-autoand the two adversarial review hunters (bmad-review-adversarial-general,bmad-review-edge-case-hunter) — whichbmad-dev-auto's step-04 invokes inline on every run — are installed in each active CLI skill tree, failing loudly with remediation instead of stalling mid-run on anUnknown command. Worktree provisioning copies these upstream skills from the main repo, since they are not bundled in the wheel. -
result.jsonworkflowis now an enforced contract on the dev path.verify_dev/verify_dev_bundlereject a mismatch againstverify.DEV_WORKFLOW("auto-dev"); the synthesized result carries"auto-dev". Review re-runs the same skill, so it carries the same tag, andverify_reviewstays purely disk-derived — it is never handed the result.json. -
Pluggable terminal-multiplexer seam (groundwork for native Windows). All tmux usage now goes through a
TerminalMultiplexerABC (get_multiplexer());TmuxMultiplexeris the only code that shells out totmuxand the only place the POSIXsh -cparked-window trailer lives. The generic adapter (renamedgeneric_tmux.py→generic.py),runs.py,tui/launch.py,probe.py, andtui/data.pyall route through it, so a future non-tmux backend slots in with no engine changes. Behavior on Linux/macOS/WSL is byte-identical; no native-Windows backend ships yet (see ROADMAP). -
POSIX portability hardening + CI guard. Scattered POSIX-only primitives are guarded behind a platform seam —
SIGKILLfallback, detach kwargs,terminate_pid,os.devnull— and the Unity plugin degrades off Linux (/proc→psutilvia a new optionalwindowsextra,/tmp,cp -aCoW, symlinks,start_new_session), keeping every Linux fast path unchanged. A newtests/test_portability_guard.pyAST/byte scan blocks new POSIX-only patterns from creeping back, with sanctioned exceptions carrying# portability:acks. -
Adapter & profile authoring guide.
docs/adapter-authoring-guide.mdnow carries the completeCLIProfile/HookSpecfield reference (the single canonical schema home) and a "writing a new adapter class" section for non-tmux transports — linked from the README documentation index.
Removed
- Retired the bundled
bmad-auto-devandbmad-auto-reviewskills.bmad-auto initnow installs three bundled skills —bmad-auto-resolve,bmad-auto-sweep,bmad-auto-setup— and the upstreambmad-dev-autoskill (from a recent bmm module) is a hard prerequisite.bmad-auto-review's adversarial review is fully covered bybmad-dev-auto's inline step-04 (Blind + Edge-Case hunters); the independent Acceptance Auditor layer is dropped, and the two hunters are now always-required base skills rather than gated onreview.enabled. The canonicaldeferred-work-format.mdmoved intobmad-auto-sweep, its remaining owner.
Fixed
- Resolving a CRITICAL escalation no longer loops on a manual-rollback prompt. Re-arming an
escalation requests a clean rebuild, which in non-worktree (in-place) runs means resetting to the
story baseline. With the default
scm.rollback_on_failure = falsethe orchestrator paused for a manual reset — but never clearedbaseline_commit, so following the instructions (git reset --hard, thenresume) re-paused on the next resume, an endless loop._rollback_or_pausenow no-ops when the tree is already at baseline (nothing this attempt touched), so a clean tree — including one the operator just reset — proceeds straight to the re-drive. The same guard suppresses the spurious prompt when an escalation left no changes at all. - Manual-recovery notice wording. The prompt no longer claims the story "failed" — it now reflects the real cause ("escalation was resolved; re-driving needs a clean baseline" vs. "attempt was stopped").
- Resolved escalations now actually re-drive instead of HALTing on a stale
blockedspec.verify_devonly recordedtask.spec_fileon a fully successful session, so a dev session that escalated with ablockedspec (the common escalation case) left it unset.rearm_escalationthen had no spec path to flip toready-for-dev, so on resumebmad-dev-auto's step-01 routing re-HALTed on the still-blockedfrontmatter — a second loop. The orchestrator now captures the spec the session produced when it escalates or defers (the synthesized result names it even on a HALT), so re-arm flips the status and the re-drive proceeds, and a deferred story's spec is stashed as intended. - Copilot dev stage no longer stalls on a subagent
agentStop. Copilot firesagentStopfor every subagent turn too — with an emptytranscriptPathand a tool-use session id, not the main session's turn-end. With dev decoupled tobmad-dev-auto(which implements via subagents), that premature Stop reached the dev stage, where 0 nudges made the orchestrator declare an outright stall before the skill wrote its terminal spec (same root cause as the 0.6.4 review stall). A new per-profilesubagent_stop_without_transcript(true forcopilot) ignores aStopcarrying no transcript, so the main session's real turn-end drives completion — and restores usage tallying, since that Stop carries the transcript. - Process liveness/termination no longer risks signaling the wrong process. A corrupt
engine.pidread as0or negative would makeos.killtarget a process group — for0, the orchestrator's own — sopid_alive/terminate_pidnow reject non-positive PIDs before signaling. The remaining liveness checks (runs.py,tui/data.py) that calledos.kill(pid, 0)directly now route throughpid_alive, since on Windowsos.kill(pid, 0)maps toTerminateProcessand is destructive; a CI guard blocks bareos.kill(_, 0)from regressing.
0.6.4 — 2026-06-21
Fixed
- Copilot token usage now records (was always 0). Copilot writes its token totals only in
the trailing
session.shutdownevents line, ~1s afteragentStop— usage was sampled before it landed.read_usagenow polls the transcript for a short grace, driven by a new per-profileusage_grace_s(8s forcopilot, 0 elsewhere = read once). - Copilot multi-turn reviews no longer stall.
agentStopfires per response turn, so a parallel-subagent review ends several turns and tripped the globalstop_without_result_nudgesdefault of 1. New per-adapter floor (5 forcopilot), overridable per stage via[adapter.review].
Added
[adapter] usage_grace_s/stop_without_result_nudges(base + per-stage[adapter.dev|review|triage]), editable in the settings TUI. Unset = inherit the CLI profile's shipped default.
Changed
- Copilot docs. Pin a capable model — the free GPT-5 mini default silently skips steps in multi-step dev/review — and it's the Copilot CLI binary that's supported, not the VS Code extension.
0.6.3 — 2026-06-21
Fixed
- GitHub Copilot adapter (CLI 1.0.63). Turn-end is
agentStop, not PascalCaseStop(which never fires) — every session previously read as a timeout. Remapped events, dropped the non-existentPreCompact, and the shared hook relay now reads camelCase payload keys (sessionId/transcriptPath). Probe mode sends its prompt verbatim so a skill-templatingprompt_templateno longer renders a missing-skill path that stalls the turn.
Added
- Copilot token accounting. New
copilot-eventsusage_parserreads~/.copilot/session-state/*/events.jsonl(data.modelMetrics.<model>.usage.*); thecopilotprofile is wired to it (wasusage_parser = "none").
0.6.2 — 2026-06-21
Added
-
bmad-auto probe-adapter(aliascollect-adapter-data). A self-service command that collects and sanitizes everything needed to finalize a CLI adapter profile — the hook payload shape, transcript location/format, and token-usage schema for ausage_parser— so a user of any coding CLI can paste back a clean, content-free report. A default zero-launch scan reads on-disk conventions; opt-in--probedoes a live capture in an ephemeral workspace. All output passes through one audited PII sanitizer (token counts and field names survive; paths, prose, and emails are redacted). -
GitHub Copilot CLI profile. Bundled
copilotprofile (Copilot CLI ≥ 2026-02):-iinteractive launch, VS Code-compatibleStophook,--allow-all-toolsfor unattended runs. Still pending live E2E and ausage_parser—probe-adaptercaptures the token schema to write one. -
Adapter authoring guide. New adapter authoring guide walks through finalizing a CLI profile with
probe-adapter(scan vs probe, the PII model, and the parser-writing loop);probe-adapteris added to both command references.
0.6.1 — 2026-06-20
Added
- Short run refs (Docker-style). Every command that takes a run id (
status,attach,resume,resolve,stop,delete,archive) now accepts a partial — the tail after the last-(e.g.a1b2, or as few chars as stay unique). Full ids still work; an ambiguous ref fails listing the candidates. Newbmad-auto list(aliasls) prints each run/sweep with its short ref, type, and status. - Flexible
--storyselection.bmad-auto run --storynow takes more than the exact full key: an epic+number (--epic 3 --story 1,--story 3-1, or--story 3.1) or a slug fragment (--story user-auth). Full keys still work. Mismatches are caught before the run launches with a targeted error — no match, ambiguous slug, or matched-but-not-actionable.
0.6.0 — 2026-06-20
Fixed
- Rollback no longer wipes non-automator files. A failed in-place attempt previously ran
git reset --hard+ a blanketgit clean -fdover the whole checkout, which could delete a project's_bmad-output/and any other untracked files (only.automator/and two artifact subdirs were spared). The orchestrator now never runs a blanketgit clean: it reverts the attempt's tracked changes and removes only the untracked files that run created, preserving pre-existing untracked files and the entire_bmad-output/tree.
Changed
- Auto-rollback is now opt-in (
[scm] rollback_on_failure, default off). With it off the orchestrator never touches your working tree on a failed attempt — it pauses the run with bold manual-recovery instructions (back up untracked files →git reset --hard <baseline>→ restore). Turn it on for the safe automatic rollback above (it discards the attempt's uncommitted work, so it warns when it fires). Worktree isolation (scm.isolation = "worktree") sidesteps this entirely.
0.5.1 — 2026-06-20
Added
-
bmad-auto clean+[cleanup]retention. Reclaims disk from concluded runs: tears down git worktrees a mid-flight stop orphaned (freeing each worktree's UnityLibrary/+ MCP-server build — the main accumulation source), trims the heavyworktrees/tree from runs kept for history (they still list in the TUI), and archives/deletes runs past[cleanup] run_retention(default 10). Only finished/stopped runs are touched;--keep/--dry-run/--retain/--hard. Everyrun/sweepstart auto-reconciles worktrees a prior finished run leaked (auto_clean_on_finish); the Unity plugin's newpost_runhook clears the IvanMurzak MCP server's/tmp/<company>/<product>/*.zipdownloads + truncates its editor log (clean_tmp). -
Test Architect (TEA) plugin. New bundled, opt-in
teaplugin that wires the BMAD Test Architect Enterprise module into every run and sweep as advisory-by-default quality steps. Enable with[plugins] enabled = ["tea"]; it injects six TEA workflows — test-design, ATDD, automate (after dev) and trace, NFR, test-review (after review) — and fails fast at startup if TEA isn't installed (npx bmad-method install→ Test Architect). Each step is individually toggleable; the three gate steps (trace/nfr/review) can be flipped to blocking, so a failing FAIL/CONCERNS gate escalates the unit for human review at commit instead of landing (fail-open: a missing or unparseable artifact never blocks). See the TEA plugin guide. -
Settings-driven workflow
enabled/blocking. A plugin can let an operator disable a[workflows.<name>]step or flip its gate from policy via the<name>_enabled/<name>_blockingsetting convention — no code, and byte-identical for plugins that don't declare them. Documented in the plugin authoring guide. -
Manage plugins from the TUI. The settings screen (
g) gains a Plugins section: a roster of every discovered plugin with an enable toggle (writing[plugins] enabled). A plugin's settings appear only once it is enabled — revealed live, hidden otherwise — so the form stays scannable; data-only plugins are always on. Saving now also runs each enabled plugin's coupling check (e.g. unityeditor_mode↔scm.isolation), blocking an invalid combo at save time instead of mid-run. -
MIT license + open-source community files. The project is now MIT-licensed (© BMad Code, LLC) with a trademark notice, and ships
CONTRIBUTING,SECURITY,CODE_OF_CONDUCT, and GitHub issue/PR templates as it becomes a first-class citizen in the BMAD org. -
Uninstall procedure. The setup guide now documents a full teardown — reclaim disk, remove
.automator/, skills, hooks, and gitignore lines, thenuv tool uninstall.
Changed
- Renamed the project and package to
bmad-auto. The distributable is nowbmad-auto(install withuv tool install 'bmad-auto[tui]') and the repo has moved to the BMAD org at bmad-code-org/bmad-auto. The CLI command, skills (bmad-auto-*), tmux sessions, andBMAD_AUTO_*env vars are unchanged. The separate legacy bmad-automator project is unrelated and stays as-is. Re-runuv tool upgrade bmad-auto --reinstallto move an existing install onto the new name.
0.5.0 — 2026-06-20
Added
- Plugin system. New
automator.pluginspackage — a general extension layer: aplugin.tomlmanifest (metadata, declarative[hooks.<stage>], a[[settings]]schema, optional in-process[python]), a folder-drop loader with builtin/project overlay (and a locked seam for entry-point packaging later), a trust allowlist ([plugins] enabledinpolicy.toml), and a registry that isolates plugin failures. A dropped[python]plugin is never imported unless explicitly enabled. Plugins can observe, veto (defer/pause/skip), and mutate a shared context at every run/sweep lifecycle stage via the hook bus, with an O(1) no-op fast path so zero-plugin runs stay byte-identical. - Dynamic, TOML-driven settings. The settings schema moves to
data/settings/core.toml(presentation only; defaults/options referenced from thepolicy.pydataclasses, never duplicated), the TUI settings screen renders from a registry, and an enabled plugin's[[settings]]appear under[plugins.<name>]. - Workflow plugins. A plugin can declare a
[workflows.<name>]table that injects an extra agent session at a lifecycle stage (post_dev_phase/post_review_result, run by thedevorreviewadapter); the prompt substitutes{story_key}/{run_id}/{scripts}. Non-blocking by default (advisory); a blocking workflow that fails routes through the normal defer path. Ships with a worked-example plugin (examples/plugins/guardrails/) exercising every extension point and a full plugin-authoring guide.
Changed
- The game-engine layer is now a plugin. Unity runs entirely through the plugin system, with
no engine-specific code in the core loop. Enable it with
[plugins] enabled = ["unity"]and configure it under[plugins.unity](editor_mode,mcp,unity_path,ready_timeout_sec,ready_grace_sec). Behavior — the readiness gate,per_worktreeEditor setup/teardown, MCP agent routing, and Library priming — is unchanged.
Deprecated
- The
[engine]policy block is deprecated in favor of[plugins] enabled = ["unity"]+[plugins.unity]. Existing[engine]configs still load but emit a deprecation warning and are folded onto theunityplugin; explicit[plugins.unity]values win.[engine]will be removed in a future release.
0.4.4 — 2026-06-19
Fixed
- Unity
per_worktree: auto-recover merge-back when a competing Editor leaks asset writes (.cs.metaGUIDs, asmdef edits) into the main checkout. Previously git refused the merge pre-flight because the target already held the unit's incoming files as dirt, escalating the unit spuriously. Merge-back now cleans only the leaked copies of this branch's incoming files (journaled asmerge-target-cleaned); dirt outside the branch's path set still escalates as possible operator work, with a distinct message. - Unity
per_worktree: route every worktree CLI's MCP config at the worktree's Editor, not just the dev agent. When dev and review use different CLIs (e.g.dev=claude,review=codex), the review agent could read a main-repo-seeded config and route its asset writes into the main checkout. Each agent's config is now written to the deterministic per-path port and verified; a mismatch fails the setup hook (the unit defers) instead of leaking writes.
Changed
- Unity engine plugin: pin the
unity-mcp-cliverification stamp to v0.81.1 (subcommand signatures re-checked; no call-site changes). Documents the new upstream dev-control HTTP bridge (dev-only, off by default, not wired) in the Game Engine MCP guide.
0.4.3 — 2026-06-18
Added
- Game-engine plugin layer (opt-in; Unity). New
[engine]policy section adapts the dev/sweep cycle to projects that drive a live engine Editor through an MCP (Unity via IvanMurzak/Unity-MCP or CoplayDev/unity-mcp); off by default. Plugins ship like CLI profiles — bundled underautomator/data/engines/<name>/, overridable in.automator/engines/<name>/.editor_modecouples to[scm] isolation:sharedruns the agent in place on the operator's open Editor;per_worktreegives each unit its own managed Editor. A readiness gate blocks until the Editor + MCP report ready before each unit, deferring on timeout instead of starting against a half-open Editor. - Unity
per_worktreemode. Each unit runs in its own git worktree with a dedicated Editor:- Launches in local (Custom) mode —
bootstrap-localplusopen --start-server trueso the Editor hosts its own per-path MCP server; this makeswait-for-readya real readiness signal before any client connects. Connection knobs overridable viaBMAD_AUTO_UNITY_MCP_*(…_LOCAL=0keeps the prior cloud launch). - Primes the worktree
Librarywith a reflink/CoW copy of the warm mainLibrary, so Unity reimports incrementally rather than cold — a cold import on a large project crashes the import workers (BurstSIGFPEwritingVirtualArtifacts). Tunable viaBMAD_AUTO_UNITY_LIBRARY_SEEDand…_SEED_MODE(reflink|copy|symlink|off). - Teardown quits the Editor and reaps its child
gamedev-mcp-serveron completion or pause, so neither leaks across runs (a leaked server holds its port and breaks the next run). - Cold-launch grace via
[engine] ready_grace_sec; MCP skill tree seeded into each worktree viaseed_globs;initnow gitignores.automator/cache/.
- Launches in local (Custom) mode —
- Worktree config seeding. A fresh worktree checks out tracked files only, so a project's
gitignored MCP/CLI configs (
.mcp.json,.claude/settings.json, …) were missing — isolated sessions then timed out reaching their MCP and escalated as spurious spec errors. Each loaded adapter's configs are now copied in before launch, via new[scm]knobsseed_adapter_defaults(default on) andworktree_seed(extra paths). Both are in the TUI settings editor. - Game Engine settings in the TUI. All six
[engine]keys (name,editor_mode,mcp,unity_path,ready_timeout_sec,ready_grace_sec) are now editable in the settings editor (g) under a collapsible titled Game Engine; theeditor_mode↔[scm] isolationcoupling is validated on save. New authoring docs: Writing a Game Engine plugin and Writing a plugin for a specific Editor MCP (fullBMAD_AUTO_UNITY_*env-var reference).
Changed
- Default
limits.session_timeout_minraised from 45 to 90 minutes — the old default cut off substantial units, especially MCP-driven Unity sessions where each Editor step is a slow round-trip. Override per project under[limits].
Fixed
bmad-auto cleanup(and the TUIcaction) no longer stops other projects' live runs. tmux sessions are global but were named onlybmad-auto-<run_id>, so a run id absent from the current project looked like a prunable orphan and matched another project's active run. Sessions and windows are now stamped with their project (@bmad_project); cleanup prunes only the current project's, while still clearing true same-project orphans. Pre-existing untagged sessions are left untouched.
0.4.2 — 2026-06-17
Fixed
- Answering sweep decisions over an attach now returns you to your terminal. After the
last decision in a cycle was answered, the session previously stayed in the orchestrator
window instead of handing control back. The sweep now returns the terminal as soon as the
current cycle's decisions are answered and continues running bundles in the background.
bmad-auto attachlands on the orchestrator window when a decision is pending and restores your previous session on exit.
0.4.1 — 2026-06-16
Fixed
- Worktree isolation (
[scm] isolation = "worktree") now works. Isolated runs previously failed on the first session withUnknown command: /bmad-auto-dev. Worktrees are now created under the run directory (.automator/runs/<run_id>/worktrees/<unit>) instead of inside.git/, and each worktree is provisioned with the bundled skills and signal hook so project commands resolve correctly.
0.4.0 — 2026-06-16
First release with opt-in git-worktree isolation for runs and sweeps. The default is
unchanged: with no [scm] configuration, work happens in place on the checked-out branch
exactly as before (isolation = "none" is byte-for-byte identical to prior behavior).
Added
- Configurable
repo_root+ Workspace seam._bmad/bmm/config.yamlgains an optionalrepo_rootkey (defaults to the project dir) that decouples "where git work + code sessions happen" from "where run state lives." All code/git/artifact access now routes through a singleWorkspaceindirection, so redirecting work into a worktree is a localized change rather than a sweep across the engine. - Worktree isolation —
[scm] isolation = "worktree"runs each story (and each sweep bundle) in its owngit worktreeon a dedicatedautomator/<run_id>[/<story>]branch cut from the target branch, then merges it back into the target locally (merge strategyff,merge, orsquash). The main checkout stays free while a run is in flight, and run state stays in the main repo's.automator/— never inside a worktree. Knobs:branch_per(story|run;runshares one branch across the run and forcesdelete_branch = false),target_branch(default = the branch checked out at run start; a configured branch is created/checked out in the main repo and never inside a worktree),delete_branch, andkeep_failed. - Failed-unit forensics. A deferred/escalated unit's full diff (tracked + untracked) is
preserved to
run_dir/failed/<unit>/changes.patch; withkeep_failed(default) its worktree + branch stay mounted for inspection.failed_diff_max_mb(default5) caps the per-file size of untracked files in that patch — oversized files are skipped with a labelled marker — andfailed_diff_unlimitedlifts the cap entirely (logs a warning when active). commit_message_template— optional[scm]template ({story_key}/{run_id}substituted) used for story and sweep-bundle commits when set.- The full
[scm]section (isolation,branch_per,target_branch,merge_strategy,delete_branch,keep_failed, the failed-diff caps, and the commit template) is editable from the TUI settings screen. (max_parallelis omitted while it stays inert.) - Low-frame-rate TUI mode.
bmad-auto tui --low-frame-rate(or[tui] low_frame_rate = true, editable from the settings screen) caps Textual to 15fps and disables animations by settingTEXTUAL_FPS/TEXTUAL_ANIMATIONSbefore the app starts. Fixes the repaint tearing/garbage seen when driving the dashboard over a slow or high-latency link (SSH, Tailscale), where a 60fps update stream can't drain in time and partial frames paint over old ones. The setting takes effect the next time the TUI launches; an explicitTEXTUAL_FPSin the environment still wins. - git worktree / branch / merge / diff primitives in
verify.py(worktree add/remove/list/ prune,create_branch,merge_branch,capture_diff, …), unit-tested in isolation.
Changed
- Worktree-mode integration is always serialized — unit branches merge into the target one at
a time.
max_parallelexists as a validated knob but is clamped to1(inert) until internal parallel fan-out is built. - Story spec paths are persisted relative to the worktree in
state.json, so a kept-failed run's state stays portable if the worktree is later moved. - The run reclaims its worktree scaffolding on clean completion (deliberately-kept failed/escalated worktrees are left in place and journalled so they can be found).
- TUI settings editor now collapses every section by default. Each policy section
(
gates,limits,scm, …) starts collapsed with a one-line description in its header, so the grown-large form scans at a glance — expand only the section you want to edit.ctrl+etoggles all sections open/closed at once.
Fixed
- A detached HEAD or unborn repo no longer lands worktree merges on an unreferenced commit — the
run pauses with a clear reason instead. A merge conflict against the target keeps the unit branch
for manual merge and escalates;
capture_diffnow raises on a genuinegiterror (rather than silently truncating the patch) andmerge_branchreports a failed abort/reset. - Editing settings no longer dirties the worktree for validation.
worktree_clean()(the pre-flight gate forrun/sweep/validate) now ignores.automator/policy.toml, so saving a change in the settings editor no longer forces a commit of the config before the next command. Only that one file is exempt — the deferred-work ledger under.automator/still commits as before.
0.3.2 — 2026-06-15
Added
- Arrow-key navigation and Enter-to-edit on the settings screen. Up/Down now move focus
between fields (additive — Tab/Shift+Tab still work), and Enter activates the focused field
by type: it opens a dropdown (
Select), toggles a switch, or enters cursor-edit mode on the multi-line box (TextArea), where the box's own Up/Down then move the cursor; Escape leaves edit mode without leaving the screen. Plain text/number inputs stay editable on focus, so Enter is a no-op there. Implemented with priority bindings gated bycheck_actionso an open dropdown or an editing TextArea keeps Up/Down, and Escape still pops the screen in nav mode.
Fixed
-
Attaching to answer a deferred-work decision now returns you where you came from. When a prompting sweep blocks on a decision (or you open a resolve session), pressing
a/Rswitches a tmux client into the orchestrator's control window so you can answer there — but on exit it left you stranded in the control session on the parked exit-status prompt instead of back at the TUI. The control window now records where the attach came from and, once you press enter, returns you: it switches the client back to the TUI's own pane when the TUI runs inside tmux (i.e. your original session), or detaches the throwaway attach client so the suspended TUI resumes when it runs outside tmux. Windows nobody attached to interactively still park unchanged. -
Empty optional numeric fields no longer flash a red "invalid" outline. The start-run and start-sweep modals draw their numeric inputs (
epic,max stories,max bundles) withtype="integer", which under Textual validates on blur and — with the defaultvalid_empty=False— treats an empty string as invalid. Tabbing past a blank field that is explicitly optional ("blank for all", "blank for no limit") therefore tripped the red$errorborder. The inputs now passvalid_empty=True, matching the settings screen, so leaving them blank is accepted silently while a typed integer still validates.
Changed
-
Clearer review toggle on the settings screen. The
[review]switch showed only the raw keyenabled, with no hint about what it controls. It is now relabelled "separate review session" and carries a muted caption spelling out both states (ON: triple review runs in a dedicated 2nd session · OFF: quick-dev runs its own tri-review inline). The change is display-only — the config key and save logic are unchanged. -
bmad-auto-setupnow upgrades, not just installs. Re-running the skill (or invoking it withupgrade) on an already-installed project is detected as an upgrade — it runsuv tool upgrade bmad-automator --reinstall(the--reinstallis required for a git source) and re-lays the per-project skills withbmad-auto init --force-skills, then reports the before → after version. Previously a re-run was treated as a config-only update: it left--force-skillsoff, soinitsilently skipped every existing skill dir and the project kept stale skills against the upgraded tool. Upgrade is detected from an existingbautoconfig section and/or a uv-managedbmad-automator, and the tool followsmainby default with an offer to pin a release tag. Docs (README "Upgrading",docs/setup-guide.md) now describe the skill-driven upgrade alongside the manual ritual, and the staleuv tool upgrade bmad-automatorhint (missing--reinstall) is corrected.
0.3.1 — 2026-06-14
Maintenance release. Also backfills the previously-undocumented [0.3.0] notes below.
Changed
scripts/sync_version.pynow runsuv lockas part of the version stamp, so a version bump regenerates the pinned lock in one command. CI runsuv sync --locked, which fails the install step on a stale lock (hit while cutting 0.3.0); folding the relock into the stamp keeps a bump a single command. Idempotent, with a loud non-zero exit ifuvis missing or the lock fails.
0.3.0 — 2026-06-14
First release carrying the optional review toggle.
Added
- Optional review pass — new policy
[review] enabledtoggle (defaulttrue). When disabled, a run skips the separate fresh-contextbmad-auto-reviewsession: the dev pass runs quick-dev's own internal triple-review unattended and finalizes the story straight todone— one session per story instead of two, with verify commands still gating the commit. The flag flows to the dev session viaBMAD_AUTO_SKIP_REVIEW=1; the dev skill (not the engine) writes thedonestatus, preserving the engine-never-writes-status invariant. Global scope: also governs deferred-work sweep bundles. Exposed as a switch in the TUI settings screen.
Changed
- Install / upgrade docs — the README install block now offers main-tracking vs.
pinned-tag installs, and a new "Upgrading" section documents the two-step ritual
(
uv tool upgrade --reinstall— required for a git source — then re-lay per-project skills withinit --force-skills). Thebmad-auto-setupskill is corrected to use--reinstall(plainuv tool upgradereuses the cached git commit and won't pull new code) and notes the skill re-lay step plus tag pinning. - Regenerated
uv.lockfor the 0.3.0 version pin.
0.2.0 — 2026-06-14
First versioned release since the initial 0.1.0. Consolidates everything built since then and
realigns the version across the Python package and the BMAD-module metadata (which had drifted to a
placeholder 1.0.0). All version-bearing fields are now kept in sync by scripts/sync_version.py,
enforced in CI.
Added
- TUI dashboard (
bmad-auto tui) — live, read-only view of runs, the sprint tree, the deferred-work ledger, a per-story phase/token table, and tailing of the journal / pane log / ATTENTION file, plus an integrated launcher for new runs and an in-app policy editor. - Deferred-work sweeps —
bmad-auto sweeptriages the ledger against the real codebase and runs full dev → review → verify → commit on actionable bundles;--repeatre-triages each cycle;bmad-auto decisionssurfaces and pre-answers human decisions earlier sweeps left open. - Interactive escalation resolution —
bmad-auto resolve <run-id>opens a resolve agent to disambiguate a frozen spec on a CRITICAL escalation, then re-arms the story and resumes. - Multi-CLI / multi-agent support — a generic tmux adapter driven by declarative TOML profiles,
with built-in
claude(default),codex, andgeminiprofiles and per-stage overrides ([adapter.dev|review|triage]) for client/model/extra args. - Run operations —
stop,delete,archive, andcleanupfor tmux artifacts of finished or stopped runs. - Cost-weighted token budgeting — per-story
max_tokens_per_storyusing cache-read weighting. - Bundled skill module — the
bmad-auto-*skills ship inside the wheel and are laid down bybmad-auto initinto.claude/skills/and/or.agents/skills/.
Changed
- BREAKING: policy
[adapter]no longer accepts the flatmodel_dev/model_reviewkeys; use the[adapter.dev]/[adapter.review]/[adapter.triage]sections instead (a clear error points at the replacement). - BREAKING: build system migrated from setuptools + pip to hatchling + uv. Install with
uv tool install "bmad-automator[tui] @ git+…"; develop withuv sync --all-extras. All docs, CLI hints, thebmad-auto-setupskill, and the eval-runner Dockerfile now use uv. - BREAKING: module layout renamed
module/→skills/; the canonical skills live undersrc/automator/data/skills/.
Fixed
- BMAD-method installer could not locate
module.yamlfor thebautomodule (collectAgentsFromModuleYaml/writeCentralConfigwarnings): restored a repo-rootmodule.yamldescriptor so the installer's shallow lookup resolves the module again. - Replaced stale
pip installinstructions across docs, CLI hints, the setup skill, the eval-runner Dockerfile, and the module greeting with their uv equivalents.
0.1.0 — 2026-06-10
- Initial release: deterministic dev → review → verify → commit orchestrator for the BMAD implementation phase, driven by a Python control loop with hook-based session transport and resumable on-disk run state.