Skip to content

v0.10.0

Latest

Choose a tag to compare

@github-actions github-actions released this 14 Aug 15:44

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 absolute BMAD_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-run bmad-loop init; validate flags a stale one as
    hooks.relay-stale, and delete/archive/clean collect the new directory with the run
    (clean --json gains state_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. init does 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 adapter field 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 adapters lists the registered kinds and the profiles selecting them,
    validate gains adapter.kind and adapter.external reports, 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:. A gate: 3-2, 3-3 line names
    the story keys that must wait for the entry, and both validate (deferred.hard-gate) and run
    (a story-gate pause) enforce it — the prose HARD 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 as deferred.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 as defer reach the sweep instead of stopping at the
    spec. Retries and replays never re-file, malformed items collapse into one severity: low entry,
    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>.json inside the story's own commit window, so bmad-loop confirm
    works from any clone; the machine-local operator-actions.json index is retired — still read and
    pruned, never written. validate reports a park with no record as operator.park-record-missing;
    pull the park commit's branch if that is what you are missing.

  • Stories can park at awaiting-operator, and bmad-loop confirm completes them (#335). A dev
    session whose story needs an action only a human can take outside the repo — publish a DNS record,
    grant an API key — now commits everything an agent can do, records what is owed in the spec's
    operator_actions: frontmatter, and parks so the run moves on, in place of a dishonest done or
    a run-halting blocked. Once you have carried the external work out, bmad-loop confirm <story-key> walks the actions (--yes skips the prompts, --reverify re-runs [verify] first
    and blocks on failure, --list/--json show what is parked), advances the spec and board to
    done and commits the pair; [operator] enabled = false reverts. 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 reached journal.jsonl,
    leaving you to hunt with git log --all; the notice now names the ref and the
    git merge --ff-only that restores it. New preserve_ref on each task is projected into status
    and --json (additive; schema stays 1).

  • Stories can close deferred-work entries (#234). closes_deferred: [DW-5, DW-6] on a
    stories.yaml entry or in a spec's frontmatter flips each named entry to status: done with a
    resolution: naming the story, at the story's commit. Advisory: a failed or escalated story
    closes nothing, bad ids only warn and validate reports 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 left logs/<task-id>.log holding nothing but the server's own INFO stdout; the SSE
    stream now also yields a curated role-prefixed transcript, a <task-id>.server.out and a raw
    <task-id>.sse.jsonl trace.

  • review.on_timeout policy 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_attempts and defer a story untouched. Adapters classify it from per-profile
    env_fault_patterns and pause like an rc 126/127 verify fault; re-arming restores the budget.

  • Raw psmux premise probes (#488). The zero-token Windows live gate now flags the workarounds a
    current psmux no longer needs, so they can be dropped on evidence rather than kept on suspicion.

  • python -m bmad_loop (#240). The package is runnable as a module, mirroring the installed
    bmad-loop console script.

  • {story_title} in scm.commit_message_template (#475). The placeholder renders the spec's
    title: frontmatter minus any leading Story <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
    characters git commit -m cannot 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-auto and 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). adapter selects which argv builder
    runs at all, so it joins the config_digest launch 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 the opencode-http class 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. An opencode-http profile
    carrying a hook dialect now gets the model warning it always needed.

  • A hookless profile can no longer wait out the clock on the generic adapter (#226). generic
    completes on a Stop hook that hooks.dialect = "none" never registers, so both routes into the
    profile map refuse the pair outright rather than passing validate and then idling until
    session_timeout_min. A pre-adapter overlay copied from the packaged opencode profile resolves
    to opencode-http instead of that dead pairing; an explicit adapter is always honored, and
    hookless on any other kind stays legal.

  • Profiles from a bmad_loop.profiles entry point are validated like TOML ones (#226). Both
    routes share one invariant set — hook dialect, path containment, env_fault_patterns compilation,
    canonical name/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 validate rather than warning. The gate: hard gate
    rides on the same bytes, so deferred.ledger-unreadable as a warning exited 0 with the gate never
    evaluated — a fail-open on the one deferred check that refuses. run pauses 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 blank status: as the token
    none. One behavior change falls out: a session that erases a previously-set status no longer
    records a transition, because a blank is not an observed live status.

  • The story token budget is checked while the story runs (#336). max_tokens_per_story was 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_session remains 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 = true and in-worktree retries.

  • A review that revokes the sprint sign-off escalates (#334). sprint-status is advanced to
    done at 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 (default escalate; retry restores the old behavior).

  • bmad-loop-setup stops registering BMAD config; the installer owns it (#258). The skill wrote
    the pre-v6.10 _bmad/config.yaml layout that BMAD's own resolver never reads, and since v6.10.0
    the installer stages the module and regenerates config.toml wholesale, discarding outside
    writes. Its PEP 723 scripts go with it, closing the bare-python3 bug (#259); setup now writes
    one help CSV, installs the tool and preflights.

  • Ctrl+C outside a run exits 130 cleanly (#241). A KeyboardInterrupt escaping main()
    during config load or engine construction now prints a one-line interrupted to stderr and
    returns the new ExitCode.INTERRUPTED, in place of a bare traceback dumped after any partial
    --json stdout. The shell exit code is unchanged, but a Python caller's subprocess.returncode
    now reads 130 rather than -2; Ctrl+C during a run is unaffected.

Removed

  • The bmad-autobmad-loop rename shims are gone. The rename shipped in 0.8.0 and no
    pre-rename installs remain in the wild, so init no longer strips bmad_auto-marked hooks,
    deletes bmad-auto-* skill dirs, carries .automator/policy.toml over to .bmad-loop/, or
    prints the leftover-.automator/ note, and bmad-loop-setup drops its migration section. A
    project still on bmad-auto should migrate on 0.9.1 — the last release carrying the shims —
    before upgrading past it.

Fixed

  • The settings editor's review.trigger help no longer names a dev primitive that may be absent.
    Its recommended blurb 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 adjacent review.enabled description and the
    docs/tui-guide.md table it mirrors; the policy.toml template init writes got the same fix.

  • The version-sync gate no longer passes on a marketplace.json it could not read.
    sync_version.check() iterated market.get("plugins", []), so a renamed, deleted or emptied
    plugins key 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 a plugins value that is not a non-empty list, and
    reports a non-object entry instead of raising. Covered by a new tests/test_sync_version.py.

  • trunk check is documented as the changed-files check it is. AGENTS.md and
    CONTRIBUTING.md both called it "full lint, no path filter", but bare trunk check lints only
    files changed against the upstream — 7 of 254 on the branch that fixed this — and CI's
    trunk-action defaults the same way. --all is the whole-repo run, and both files now say so.
    CONTRIBUTING.md also no longer claims release.py check rejects a hand-authored version
    section (it returns 0 on one), tells maintainers that prepare refuses 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.md never mentioned
    pyright or the CHANGELOG, so its verify step — "trunk check and uv run pytest -q both
    pass" — sent contributors into a CI failure on the dedicated typecheck job. It now carries
    uv run pyright, the ## [Unreleased] CHANGELOG contract, the Windows PYTHONUTF8=1
    requirement and the Python 3.11 floor, the real extras (tui, non-linux, opencode — it
    claimed only tui), and scripts/release.py's two-phase prepare/publish flow. 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 plus docs/README.md,
    the full index. docs/FEATURES.md now covers all 15 policy sections rather than 12 — [dev] was
    documented nowhere. The roadmap's native-Windows entry read planned while its own body said
    psmux had shipped, and AGENTS.md counted ~25 leaf 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, ENOSPC or EIO partway 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 lands 0600 rather 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.md now
    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).
    Advancing 3-2-x: "a # b" produced 3-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, or board-advance-carry-uncommitted where git refuses the ignored path
    (the ordinary outcome for such a board), or board-advance-carry-failed where 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.

  • confirm no longer loses its commit to a gitignored board (#577). Confirming a park on such a
    board printed ✓ confirmed while the spec's flip to done and the park record's deletion stayed
    uncommitted, dirtying the tree the next run's preflight refuses. The board is now left out of that
    commit when git will not take it — a force-tracked board still commits — and is advanced on disk
    either way.

  • resume's config-change baseline moves out of the agent-writable tree (#498). The baseline
    resume compares against — verify commands, launch binary/args/env, plugin allowlist —
    round-tripped through state.json, inside the very tree the digest exists to police, so a session
    that rewrote policy.toml could blank the field in the same breath and the warning never fired.
    It now lives in the run's out-of-tree state dir (#494) and is collected by the same
    delete/archive/clean lifecycle; state.json keeps 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.json arrived 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 pinned skip-worktree so 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.

  • cleanup no 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 into removed / survived / unverifiable, so
    CLEANUP_SCHEMA_VERSION is 2 and text mode names the two non-removed arms on stderr. A
    candidate scan that fails outright reports ctl_windows.scan_error with empty arms, so a
    preflight failure is never the same document as "nothing to prune"; sessions.removed is
    untouched and still an attempted kill.

  • A crashed version probe is distinguishable from "reports no version" (#428). A binary on
    PATH that 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 mux now prints the dropped
    diagnostic as a warning: 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,
    diagnose and validate included: the host.win32-on-wsl-path warning 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=inf no longer disables the session timeout. The guard rejected a
    non-positive value but let inf, 1e999 and Infinity through, 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 to limits.session_timeout_min as it already did for 0 and
    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, archive and clean gated 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 a bmad-loop-<run-id> session the project cannot prove foreign is live, and clean leaves
    the run untouched; --force overrides 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 clean removed 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-id is caller-supplied and may contain -, so run-other-RID satisfied the lookup for
    RID and sorted ahead of it — bmad-loop x could 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 and x
    all landed on a parked run's dead window while the live one ran on. Each launch now records the
    window id it minted and the lookup prefers it, and a resume whose id was not captured warns rather
    than reporting plain success. Adapter authors: the re-prove pairs new_parked_window's id
    with the window_id column of list_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 scored crashed and read as an agent fault. A crash verdict now probes for the
    session and carries session_vanished in the failure reason, on every role's session-end entry
    and as a session-vanished breadcrumb, 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
    PATH to its own, so a bash prompt can reach a Windows-installed bmad-loop that takes the psmux
    platform default and never sees the distro's tmux — while validate printed a green multiplexer
    line and nothing named the platform. validate now reports the multiplexer selection reason for
    every host, so platform default for win32 is on screen wherever the mismatch happens, and a
    fallback selection is a warning rather than a green line. A win32 interpreter working on a
    \\wsl.localhost\... project additionally raises host.win32-on-wsl-path naming the fix (install
    with the WSL/Linux Python) and the backend it actually chose; diagnose gains sys.platform and
    win32 on WSL distro path. A project under /mnt/c gets a genuine Windows path and no warning.
    Nothing changes which backend is selected, nor validate's exit code.

  • A multiplexer-detection failure is reported instead of swallowed (#332). validate caught and
    discarded any exception from backend detection, so the selection and the backend inventory
    vanished with nothing said — while the healthy-looking mux.backend line above them, which comes
    from an independent call, still printed. It now reports under mux.backends-detected at
    warning, carrying the error.

  • Provider quota refusals are environment faults on opencode-http too (#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 the opencode profile 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 lower scm.preserve_keep if it ever fires.

  • The auto-sweep child refuses config a session rewrote under the run (#461). policy.toml and
    profiles/*.toml sit 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] enabled gates 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-failed plus a notification, the parent run
    continues), while resume re-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, and validate stats 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 to session_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.registered never touched the script it points at, so a
    deleted .bmad-loop/ (a branch switch) read green while every hook event no-opped; a new
    hooks.relay-present finding stats the relay and says run 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.yaml change — 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 at done or awaiting-operator is 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's seed_files and
    skill_tree, hooks.config_path, a plugin manifest's seeds and its Python module, and the Unity
    seeder's scene guard now refuse every spelling. Behavior change: a non-string seed entry is
    rejected rather than silently coerced.

  • init no longer follows a config path that leaves the project (#456). The profile and
    manifest guards are lexical, so a skill_tree, hooks.config_path or plugin module naming an
    ordinary project-relative directory passed them even when that directory linked out of the tree —
    and unlike worktree provisioning, init reached 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 past init 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 one error: 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_files does and codex's
    does not. Every non-hookless profile's resolved config_path is 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, journaled review-followup-carried / review-followup-carry-uncommitted and
    story-deferred-close-carried / story-deferred-close-carry-uncommitted.

  • A host lost in the merge-to-carry window replays a carry that filed no findings (#433). The
    resume pre-pass only replayed units carrying harvested findings, so a unit whose sole ledger
    payload was a bundle's closures, a damped review follow-up or a story'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 open entries
    are re-bundled a wrongly done entry 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 records ledger-snapshot-missing
    instead of passing in silence. The revert is lossless either way: a spec's deferred: 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 journaled worktree-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's customize.toml requires;
    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 journaled worktree-module-skills-dropped: informational, never a pause,
    because these skills dispatch at the main checkout and their absence in a worktree stalls no
    session.

  • Worktree provisioning survives a filesystem it cannot fully read (#422). A single unreadable
    file, dangling link, symlink cycle or FIFO in the repo's skill trees or seed sources ended the
    whole run with a traceback where a named, resumable escalation belonged. Every probe and copy the
    isolated seed makes is now total, on one shared walk that descends symlinked source directories
    without looping. Only provisioning degrades.

  • A worktree's hook config is never registered through a symlink (#421). A checkout carrying its
    per-CLI settings file as a symlink out of the tree had the registration read the outside file
    and write there — mutating the operator's real dotfile while the worktree, left with no Stop hook,
    never reported completion. The whole profile is now refused unless the path is inside the worktree
    with no symlinked component on the way to it.

  • A renderer stub that cannot compose its prompt now fails the preflight (#410). A stub
    SKILL.md (BMAD-METHOD#2601) shells out to a renderer script; when that cannot run it writes
    HALT: … and the session stops with no spec — a fact about the install, so every story does the
    same. Three problem findings now refuse it up front: skills.dev-renderer for a short script
    unit, skills.dev-renderer-config for an absent _bmad/config.toml, and
    skills.dev-renderer-sources for a missing workflow.md or snapshot target.

  • Refuse isolation = "worktree" combined with a repo_root override (#414). The pair produced
    a green preflight and then an isolated session with no dev primitive, no result, and nothing
    journaled naming the cause. validate now reports it; run, sweep, resume and 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_artifacts reaches 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.

  • diagnose pseudonymizes the spec name, and gives one spec one alias (#433). A journal
    record's spec field carries the customer's feature name, and a bare basename is
    identifier-shaped, so the scrub fallback shipped it verbatim. spec now has an alias namespace of
    its own and the value is reduced to its basename first — the producers disagree on shape, so
    without that one spec drew two aliases and an absolute home path landed in the local --legend
    file.

  • The upstream bmad-dev-autobmad-build-auto rename no longer breaks a project (#393). The
    dev primitive is resolved on disk — bmad-build-auto preferred, a marker-complete bmad-dev-auto
    accepted — so validate, run, sweep and resume pass on either era with no policy.toml
    edit. skills.base-shim refuses 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/skills and .agents/skills at 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-run says on stderr when its
    preview is not runnable.

  • The git-add shield no longer hides new files in your own checkout (#384). It appended the tool
    files it writes — skill trees, hook config, seeded configs — to .git/info/exclude, which is
    shared with the main checkout and every sibling worktree, permanent and unversioned; projects
    legitimately track those paths, so every new file under them silently stopped being staged by
    git add -A. The shield now writes a private exclude in the worktree's own gitdir, activated by a
    worktree-scoped core.excludesFile that dies with git 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 file git rev-parse --git-path info/exclude names, delete the
    shield's own lines (typically the skill trees, the hook config, the /_bmad family and any
    [scm] worktree_seed path), and use git 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 empty core.excludesFile as "no excludes file at all". An unreadable file, an
    unresolvable git home, a peer-accessible core.sharedRepository or 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 .git lock.

  • The git-add shield's git calls run on the shared chokepoint (#389). They were the last bare
    git spawns outside it, so they missed its LC_ALL=C pin 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 log surfaces 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, and git diff, whose failure bypassed the valve
    that preserves a failed unit's work. It is now a GitError like 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 ENOSPC or EIO partway 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-parse was
    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 journaled worktree-exclude-degraded reason —
    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-present status: read
    as the truthy token none, so the prose ## Auto Run Result fallback 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 prose blocked or
    awaiting-operator now gets a truthful label too; routing is unchanged.

  • A blank frontmatter status: reads as blank, not as the token none (#358). YAML parses a
    bare status: 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
    literal status: none stays the string; confirm renders status: (blank) and the stories-mode
    board reads present.

  • 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, so status: draft # set by hand came
    back as status: 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 with
    yaml.safe_load as an oracle and kept only if the block still parses as a mapping, its status
    is the target, and every other key is unchanged.

  • A status no line edit can safely move now raises instead of reporting success (#335). False
    now means "nothing to change", so setting an already-done status 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 try in the commit phase — so an escape skipped the park notification, the
    post_commit hook and the state save. Both guards now hold the type, and the writer degrades to
    its operator-index-failed journal line.

  • An unwritable ATTENTION file 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_story is validated like its per-session sibling. It was parsed with a
    bare int(), so true silently became a 1-token story cap and 0 was accepted, both against the
    documented int >= 1. Non-integers and values below 1 now raise PolicyError at load. Note this
    rejects a policy.toml that previously loaded; there is no 0 = off semantic — 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-deferred journal
    entry, no defer notification, an under-counted defer_count in diagnose. 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 OSError is translated at the git chokepoint (#343). Only a timeout was
    translated, so an EMFILE, ENOMEM or ENOENT out of the spawn itself bypassed every
    OSError-blind except GitError guard and crashed the run — under exactly the resource pressure
    those guards exist for. Spawn faults now raise a typed GitSpawnError with 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_rollback no longer swallows a failed git stash create (#340). The empty snapshot
    silently disabled the whole preserve restore, 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 OSError during the attempt snapshot no longer crashes the run (#343). An
    EMFILE or ENOMEM escaped 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 a GitError
    takes, keeping the errno as the breadcrumb; safe_reset still 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-failed apart from attempt-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, a status
    outside open/done <date>, or a severity outside critical|high|medium|low now raises rather
    than landing in the file. The inner dev session and the sweep's migration session still write
    ledger markdown directly, so the sweep skill now states the single-line expectation — picked up on
    bmad-loop init --force-skills.

  • A deferred finding appended after the last ledger entry is no longer lost (#304). Found and
    first fixed by @Haven2026 in #274. The inner dev session appends
    each defer as a flat block, which the last canonical ### DW-<n> entry's span absorbed — so it
    was invisible to the sweep, to --dry-run and to the TUI's legacy view. Spans now end at a flat
    block, and append_entry writes the documented location: 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) versus UNREACHABLE (no client at
    all), which journals sweep-return-no-client.

  • bmad-loop mux renders 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
    in diagnose --json and in validate --json's mux.backend and mux.backends-detected
    findings; the field shapes are unchanged.

  • bmad-loop mux explains 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 Windows tmux is 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 a note: naming them instead. A forced backend is exempt:
    calling it unselectable would contradict the * marker one line above.

  • Both mux client verbs now owe effect, not dispatch (#317). TerminalMultiplexer.detach_client
    widens from None to bool. 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 to False rather than a vacuous True
    where that is unobservable. Out-of-tree backends still returning None read 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_@3 died with window @3; keys now carry a
    seam-owned marker (@bmad_project__blw@3) only a deliberate imitation collides with.
    kill_window frees 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); the switch-client leg stays inert on builds predating psmux/psmux#483,
    still inert at 3.3.7.

  • Session-qualify the psmux TUI-side window ids (#291). #254 covered the engine seam but left
    the launcher's surfaces bare, and that process usually runs outside any pane — where a bare @N
    resolves through psmux's most-recent-session fallback rather than the session that minted it, so
    the control-window prune could kill another server's identically-numbered window at rc 0. The
    launcher's window ids now carry their session, and select_window resolves the id to an index
    first.

  • Verify environment faults are classified per shell, so Windows stops burning attempts (#302).
    The {126, 127} convention is sh's; cmd has no equivalent — a missing tool exits 1, 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's is not recognized /
    access is denied, and a probe that also catches a file outside PATHEXT exiting 0 unrun — a
    pass verify never earned.

  • psmux window ids are now session-qualified (#254). psmux mints window ids per server, so a
    bare @N replayed as a target routed by the caller's own $TMUX — from a bmad-loop-ctl pane,
    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 — a Stop event 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, journaling readback-refused-no-proof-of-work.

  • validate requires 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 — so validate, run,
    resume and sweep all failed on a stock install. The reviewers now come from the installed
    skill itself: its customize.toml review 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 a skills.review-layer-missing problem instead of passing and then failing on
    every dev run; disabling every layer is skills.review-layers-empty, an unreadable override is a
    skills.customize-unreadable warning, and a layer gated by a run-time condition is
    skills.review-layer-unresolved. validate and 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.desktop works on macOS/Windows, and warns when it can't (#231). The channel was
    notify-send-only, so on macOS and Windows it was inert while defaulting to true — 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, validate warns notify.desktop-unavailable and 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: done without the terminal
    ## Auto Run Result block the harvest scan keys on, so every Stop read no-artifact, stall
    nudges re-invoked an already-exited workflow (#149), and after max_review_cycles the 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_frontmatter and journal session-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-review flip could score done without
    running. The engine now snapshots the spec at review launch and refuses a candidate still hashing
    equal to it (unmodified-since-launch), while an observed mid-session status transition proves
    the session ran and outranks that gate, collapsing the two-Stop fingerprint to one sighting.

  • A synthesized result repairs the spec (#276). The marker the skill owed is written back
    atomically, refused outside the orchestrator-owned roots or where the fresh frontmatter disagrees
    (spec-marker-repaired, spec-marker-repair-failed, spec-marker-repair-skipped). The launch
    status: is never mutated: it is load-bearing routing input to the upstream skill.

  • limits.dev_contract_nudge (default true) 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 it false to rely on harness-side synthesis alone.