diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index c121858b..3a9f92ab 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -12,7 +12,7 @@
"name": "bmad-loop",
"source": "./src/bmad_loop/data/skills",
"description": "Automation-mode skills driven by the bmad-loop orchestrator: interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill",
- "version": "0.9.0",
+ "version": "0.9.1",
"author": {
"name": "pinkyd"
},
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eda77a89..f5caec18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,384 @@ All notable changes to `bmad-loop` are documented here. The format is based on
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0,
breaking changes may land in a minor release.
+## [0.9.1] — 2026-08-01
+
+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 now resolved on disk, so the upstream rename no longer breaks a
+ project (#405).** `bmad-build-auto` is preferred and a marker-complete `bmad-dev-auto` is
+ accepted, so `validate`/`run`/`sweep`/`resume` pass on either era. The forwarding shim
+ upstream left behind is refused by a new `skills.base-shim` check — its migration prompt is
+ interactive and would HALT an unattended session with nothing written to disk. The ok line
+ and `validate --json` name the primitive that actually resolved, and worktree isolation
+ copies the new skill directory. `[dev] skill` stays `bmad-dev-auto`: it is the adapter
+ discriminator, not the invoked name.
+
+- **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 resolved per skill tree, so a run mixing
+ `.claude/skills` and `.agents/skills` gets the right one per role, and the no-spec fallback
+ result marker is read under both the `bmad-build-auto-result-*` and legacy prefixes.
+
+- **`--dry-run` says when its preview is not runnable (#405).** `run`, `sweep` and stories dry
+ runs returned before their own skill preflight, so a broken install still got a
+ plausible-looking schedule — and post-rename the previewed `/bmad-dev-auto` is a _valid_
+ command that would HALT on the shim. The preflight failures now print to stderr under a "NOT
+ runnable as-is" banner. Exit code stays 0 and stdout is untouched: a dry run is a diagnostic,
+ and rc 0 has always meant "the preview rendered", not "the project is ready".
+
+- **The skills preflight no longer gates a triage-only CLI's skill tree (#405).** Every
+ `skills.*` check asks a dev-primitive question — which primitive resolves, whether the three
+ review hunters it invokes inline are installed, whether a renderer stub's script unit is
+ whole — yet the tree list was built from all three adapter roles. So a `[adapter.triage]` of
+ `gemini` beside a claude dev/review pair demanded the whole BMad Method module in
+ `.agents/skills`, a tree whose only prompt (`/bmad-loop-sweep`) ships in this wheel and is
+ laid down by `bmad-loop init`. The over-breadth is pre-existing and was already a hard block —
+ 0.9.0 emitted `skills.base-missing` as a problem over the same over-broad tree list and its run
+ preflight refused on it — but the new renderer checks route through the same list, so 0.9.1
+ added new ways for one to fire. The probe — and `validate`'s own copy of it, which had the same defect twice
+ — is now scoped to the dev and review roles, the same set `Engine._worktree_profiles`
+ provisions into a worktree, and the `skills.base` and `skills.stories-dispatch` ok lines
+ report that narrower `trees` list. Single-CLI projects and dev/review splits across two CLIs
+ are unaffected.
+
+- **A worktree that could not be given its upstream skills now pauses instead of stalling
+ (#405).** `provision_worktree` copies the BMad Method skills from the main repo behind a
+ containment guard, and skipped anything resolving outside the repo — which is exactly what a
+ skill tree symlinked to a shared machine-wide BMad install does. The run-start preflight stats
+ through that symlink and passes, so an isolated run dispatched into a worktree holding none of
+ its skills and every session stalled on `Unknown command` having written nothing. Provisioning
+ now reports the skills it could not deliver through the existing `worktree-seed-skipped`
+ journal channel, and the engine escalates and pauses before dispatch, naming them — the same
+ environment-fault treatment the renderer surface already got, since the seed reads the same
+ repo for every story. Unlike the renderer legs this carries no _renderer_-era condition: a
+ missing skill stalls a session whether its `SKILL.md` renders or is inline. It is scoped to the
+ primitive era the run actually dispatches, though — the skills list it reads names both eras
+ because copying one that isn't there is free, but a leftover `bmad-dev-auto` shim no prompt
+ spells is not a stall to pause over. The copy itself now merges per _file_, so a worktree that
+ already holds part of a skill directory is completed rather than skipped whole, and the
+ completeness check walks the repo's skill directory exactly the way the copy walks it,
+ reporting every file the seed could not deliver, naming the file rather than the directory
+ whenever the directory itself arrived (a wholly-absent skill still reports the coarse rel). A
+ fixed list of required files would have covered three of the thirteen a real `bmad-build-auto`
+ install carries — and would go stale the next time upstream renames a step file. Containment
+ is checked per file too, so one skill file, or one whole sub-directory, symlinked to a shared
+ install outside the repo is refused rather than read through, and reported rather than
+ silently missing. A skill tree that is committed (symlink and all)
+ is checked out into the worktree normally and is unaffected. Pre-existing since 0.7.0, which
+ is where the containment-guarded seed loop first shipped; found while reviewing this release.
+
+- **A renderer stub that cannot compose a prompt fails the preflight (#405).** Three new checks
+ block `validate`/`run`/`sweep`/`resume`: `skills.dev-renderer`, when the resolved `SKILL.md`
+ is the new renderer stub (BMAD-METHOD#2601) but its script unit is not whole —
+ `_bmad/scripts/render_skill.py` or the `_bmad/scripts/config_utils.py` it imports at module
+ scope; `skills.dev-renderer-config`, when a stub resolved but `_bmad/config.toml` — the
+ renderer's one required config layer — is absent; and `skills.dev-renderer-sources`, when the
+ skill's own render sources are short: no `workflow.md` for the renderer to compose from, or a
+ `[[bmad-snapshot:…]]` token naming something the renderer will not load as a source. Every
+ route ends in a
+ session that Stops having written no spec, and every one of them is a fact about the install
+ rather than the story, so every story after it does the same. A missing config exits `HALT:`;
+ a missing entry document or snapshot target does too. A missing script or sibling loses even
+ that line — `uv` fails before the renderer runs, or `ModuleNotFoundError` fires above its own
+ guard — leaving only the stub's own instruction to report the output and halt. The sibling is
+ required only when the installed `render_skill.py` actually imports it, and the source check
+ asks what the install itself declares — save the `workflow.md` entry name, which upstream
+ hardcodes too — so a later renderer that inlines the helper or
+ reorganizes its step files is not refused. They block rather than warn because only a green is
+ untrustworthy (uv on PATH is never probed) — a red is conclusive. The config check is emitted
+ once per project and only when a stub actually resolved, so a pre-renderer install stays
+ silent, and `--dry-run` names all three under its "NOT runnable as-is" banner. A fourth check,
+ `skills.customize-legacy`, is a warning: it fires when a tree resolved to `bmad-build-auto`
+ while an override still sits at `_bmad/custom/bmad-dev-auto[.user].toml`, where it no longer
+ applies — the session still runs, just unstyled.
+
+- **Worktree isolation carries the `_bmad/` config surface (#405).** The renderer-era primitive
+ is handed 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, this one included) every
+ isolated session HALTed with nothing written. `provision_worktree` now merge-copies the repo's
+ `_bmad/` per file, copy-when-absent, so a checkout that commits it keeps every tracked file and
+ only the gitignored layers are filled in. `_bmad/scripts/` is seeded whole rather than curated
+ because `render_skill.py` bare-imports its sibling `config_utils`, and a seed that comes up
+ short — the realistic trigger is a symlinked `_bmad/`, how a shared BMad install is wired —
+ pauses the run _when the resolved dev primitive is a renderer stub_. Every story would drive
+ the same incomplete seed into the same result-less Stop, so it escalates once with the
+ worktree left mounted for inspection, rather than dispatching the whole backlog and reporting
+ `0 done`. `_bmad/config.toml` is checked the same way and named separately in the escalation:
+ a repo that centralises only that file behind a symlink seeds a complete `_bmad/scripts/` and
+ no config at all, which was silent from every angle — the repo-side preflight follows the
+ symlink and passes. On a pre-BMAD-METHOD#2601 inline `SKILL.md` nothing reads either path,
+ so the short seed costs the run nothing and stays an ordinary journaled report. Neither
+ sentinel can be forged: a `worktree_seed` entry spelling one is dropped rather than reported,
+ so a checkout that commits its whole `_bmad/` is never paused on a healthy worktree.
+ `_bmad/render/` is never seeded and is git-excluded inside the worktree, so the renderer's
+ in-session rewrite of it cannot be swept into a story commit; `init` now gitignores it as
+ well, which is the only protection under the default `isolation = "none"`.
+
+- **Worktree provisioning survives a filesystem it cannot fully read (#405).** The `_bmad/` seed
+ and the detector that checks it now share the skill seed's walk: `rglob` does not descend a
+ symlinked _sub_-directory and the detector mirrored the same `rglob`, so a symlinked
+ `_bmad/scripts/lib` one level in was seeded as nothing and reported as complete — the
+ under-seed and the check that should have caught it were wrong together. Pointing inside the
+ repo it is now seeded; pointing outside it is still dropped by the containment guard, but the
+ gate can see it.
+ That walk descends symlinks, so it carries a branch-local cycle guard — without one,
+ `_bmad/scripts/loop -> ..` is copied at every depth until the kernel's `ELOOP` stops it, a risk
+ the old `rglob` never ran because it refused the descent to begin with. Per file, a source the
+ walk cannot see inside, an occupied destination and a failed read or write are each a skip the
+ skills gate then names — a source that could never be delivered at all, a dangling link or a
+ FIFO, is still dropped by both halves in silence, and `_bmad/`'s own report stays the two
+ renderer sentinels — rather than an exception out of the seed: `provision_worktree` runs inside
+ `Engine._run_isolated` with no `try` around it, so one unreadable skill file ended the whole run
+ with a crash dump where a named, resumable escalation belonged. A dangling _destination_ symlink
+ now counts as occupied: writing through one landed the bytes at the link's target and left the
+ gate reading green through the now-live link. Every probe the skills and `_bmad/` seeds make is
+ now total — and, the part a `try` alone does not buy, reads the same on every supported
+ interpreter. `is_file()`/`exists()`/`is_dir()` raise on an unsearchable parent through 3.13 and
+ answer `False` on 3.14, where `False` is the silent drop and not the cautious reading, so a
+ `False` from `is_dir()` is re-asked of `stat()`, which still raises there. Two faults reach that
+ split: a directory readable but _not searchable_ (mode `0o444`, where listing succeeds and
+ stat-ing each child does not) and an unreadable skill tree one level above the walk's own root.
+ What the report can say degrades with what the filesystem gives up: a directory that cannot be
+ listed is named once, and one that can be listed but not stat'd through is named file by file.
+ The eager copies are unchanged and can still raise — user-authored `worktree_seed`, the
+ adapter-default and plugin seeds beside it, and the per-CLI hook-config write.
+
+- **A seed the worktree never got is now reported, and the `_bmad/` shield writes one exclude line,
+ not two (#405).** The two explicit seed loops drop an entry with a bare `continue` when the
+ resolve-and-contain guard refuses it, so a config the repo carries as a symlink _out_ of itself —
+ a dotfile-managed `.claude/settings.json`, a shared MCP config — delivered nothing and said
+ nothing: `worktree-seed-skipped` reports only the opposite case, an entry whose destination
+ already exists. Provisioning's result is now re-probed against the repo and the drops journaled
+ as `worktree-seed-dropped`, asking the two trees on disk rather than the loops' bookkeeping, so a
+ drop by any future path is caught the same way. Journaled and never escalated, unlike the
+ `_bmad/` and skills gates beside it: those name files the orchestrator dispatches or the renderer
+ HALTs on, while a seed entry is arbitrary user config whose canonical trigger is an ordinary
+ working setup, so pausing would refuse every run of such a project over a guard doing its job. A
+ broken link stays silent in both halves, as it already does for the skills gate; a FIFO does
+ not — these loops gate on `exists()`, so the copy reaches it and raises. The eager copies are
+ still unchanged: a copy that _fails_ raises rather than landing here.
+ `/_bmad/render/` goes into `.git/info/exclude` only when the blanket `/_bmad` this release also
+ adds is not — that file lives under the git common dir a linked worktree shares with the main
+ checkout and nothing here ever prunes it, and `/_bmad` prunes the directory before git descends,
+ so a sibling line would be permanent state in the operator's own repo that git never consults. `worktree-opened` is
+ journaled as soon as the worktree is mounted rather than after every provisioning gate has
+ passed, so the escalations that leave a half-provisioned worktree mounted for inspection now say
+ where it is.
+
+- **Seeding never writes _through_ a symlink, and a hook config is no longer its own alibi
+ (#405).** Both explicit seed loops resolved the destination and then used the resolved path for
+ the occupancy probe, the `mkdir` and the copy. `Path.resolve()` is non-strict, so a dangling
+ link answers for its target: `exists()` reported the slot free and the copy landed at the link's
+ target instead — an unrequested path inside the worktree that the exclude does not name and the
+ unit's `git add -A` would stage into the story branch. `git worktree add` produces exactly that
+ state for a tracked symlink whose target is untracked, so it arrives on a project's _first_
+ story. Both loops now compare the raw destination against the resolved one and refuse when they
+ differ, each with its own guard — they share no code — and strictly _after_ the
+ copy-when-absent skip arm, since a live symlinked destination differs too and hoisting the
+ refusal would turn that ordinary no-op into a silent drop. What is refused is named afterwards
+ by `worktree-seed-dropped`, as every other refusal in those loops already is. The drop report in
+ turn stops trusting the destination for the one rel that cannot answer for itself: the per-CLI
+ hook step writes `profile.hooks.config_path` after both seed loops, so a config the loops
+ dropped is answered for by the hook's own bytes — and for gemini, copilot and antigravity that
+ path is the profile's _only_ default seed, so the false green was the gate's entire answer.
+ Those rels are now asked whether the source escapes the repo, the destination is a symlink, or
+ the destination escapes the worktree, replacing the existence probe rather than adding to it so
+ a destination that trips two of them is still named once. A live symlinked destination for a
+ hook config is consequently named here as well as in `worktree-seed-skipped`; both facts are
+ true at once and this channel journals rather than pauses. Two destination-containment guards —
+ the `_bmad/` merge's and the skill merge's — also gain their first tests: their existing
+ siblings arm the source guard and the occupancy check instead, so a live symlinked _directory_
+ left them unwitnessed.
+
+- **`isolation = "worktree"` is refused when `repo_root` is overridden (#405).** A project whose
+ `_bmad/bmm/config.yaml` sets `repo_root` decouples the git root from the project dir — the
+ documented monorepo knob — but worktree provisioning reads `repo_root` for every surface it
+ seeds off disk (the upstream skill trees, `_bmad/` and its `_bmad/custom/` overrides, every
+ `seed_files`/`seed_globs` entry) and bakes the absolute hook-relay path from it into the
+ worktree's hook config, while `init`, `validate` and the run
+ preflight write and probe them under `project`. `load_paths` requires
+ `project/_bmad/bmm/config.yaml`, so that surface is under `project` by definition and
+ `repo_root/_bmad/` generally does not exist: the preflight approved a surface the isolated run
+ never received, every seed-completeness gate above went inert rather than firing, and each
+ story ended as a result-less Stop with nothing naming the cause. `validate` now reports the
+ pair as a `policy.isolation-repo-root` problem, and `run`, `sweep`, `resume`, `resolve`'s
+ re-arm and the auto-triggered child sweep refuse to start, naming both remediations — drop the
+ `repo_root` override, or set `isolation = "none"`. The child sweep raises rather than declining
+ quietly, so a mid-run flip is journaled as `sweep-auto-failed` instead of being recorded as a
+ sweep that ran. The dry-run banner lists the refusal ahead of the skill
+ ones it aborts before, and the TUI's pre-launch guard mirrors it — ahead of its own clean-tree
+ gate, as the CLI does — so the operator gets a toast rather than a pane that dies.
+ Pre-existing since worktree isolation shipped. Plumbing
+ `project` through provisioning instead — which would make the combination work rather than
+ refuse it — stays open as #414.
+
+- **`validate` warns when the renderer's output is already committed (#405).** The two shields
+ 0.9.1 adds — the `_bmad/render/` line `init` writes into `.gitignore`, and the `/_bmad/render/`
+ line provisioning writes into the repo's local git exclude — only help going forward, because a
+ tracked path ignores both entirely. A project that committed `_bmad/render/` earlier keeps
+ churning it into every story commit, gaining a snapshot directory per machine, per checkout path
+ and per upstream renderer bump, since the path is keyed on a hash of the absolute project root
+ plus a generation hash over the renderer, its sources and the resolved config. A new
+ `git.render-tracked` warning names the one-time
+ `git rm -r --cached _bmad/render`. A warning and not a problem — nothing about a tracked
+ `_bmad/render/` stops a session — and outside a git repo it stays quiet rather than fabricating
+ an ok (#409). All three sites now spell the path from one `install.RENDER_DIR_REL`, so the
+ probe cannot drift away from the shields it reports on.
+
+- **`verify`'s emptiness probes no longer read their answer out of git's stderr (#405).**
+ `worktree_clean` — and `path_tracked`, added earlier in this release — tested stdout and stderr
+ merged, but `ls-files` and
+ `status` exit 0 while still writing to stderr — a `core.fsmonitor` hook that cannot exec, an
+ unknown `core.fsyncMethod` — and that chatter is indistinguishable from an index entry or a
+ porcelain line. Both answers were silently inverted: the new `git.render-tracked` check would
+ have told operators to `git rm -r --cached` a path that was never committed, and a checkout with
+ nothing in it was reported dirty, blocking `run`, `sweep` and `validate` outright. Both now read
+ stdout alone; the error paths keep the merge, where stderr is the informative half.
+
+- **An undecodable `policy.toml` or `config.yaml` is reported, not a traceback (#405).**
+ `read_text` raises `UnicodeDecodeError` on a file saved as UTF-16 or latin-1, and that is a
+ `ValueError`, not an `OSError` — so it escaped every `except (PolicyError, OSError)` handler in
+ the codebase, which are precisely the ones whose job is to degrade to defaults. The TUI could
+ not open its dashboard, and `_configure_mux` runs before argument dispatch on every command, so
+ the CLI died at startup on the raw codec message its catch-all prints, naming no file, instead
+ of any of its named findings. `policy.load` and `bmadconfig.load_paths` now convert it to their
+ own typed errors, which fixes every caller at once; `validate` reports the file by name.
+
+- **Deferred review findings are harvested out of the spec's frontmatter (#405).**
+ BMAD-METHOD#2640 moved `defer`-triaged findings from `deferred-work.md` into the spec's own
+ `deferred:` list, silently starving the sweep pipeline. A successful dev or review session
+ now files each finding into the ledger with its `location` and `severity`, journals
+ `spec-deferrals-harvested`, and dedups on a fingerprint of the summary and location so a
+ _fixable_ retry, a resume replay or a second review pass never re-files one — including after a
+ sweep has already marked it done. (A NON-fixable retry re-files deliberately: the bullet below
+ reverts its entry along with the attempt, and the next attempt harvests it fresh.) Malformed items do not block their well-formed siblings: the loss is
+ journaled and filed as one aggregated ledger entry. The spec's frontmatter is never rewritten,
+ and the harvest keys on content rather than the installed skill name, so it works on both eras.
+ The review prompt no longer also asks the session to file the finding itself: that produced two
+ entries per finding which could never dedup, since an agent-written one carries neither the
+ fingerprinted `origin:` nor a `source_spec:` line. It stays neutral rather than banning ledger
+ edits outright — on a pre-BMAD-METHOD#2640 skill there is no frontmatter to harvest and the
+ session's own append is the finding's only record — and still forbids rewriting existing entries.
+
+- **A harvested deferral is reverted when its attempt rolls back (#405).** The harvest keys on
+ the spec's status and runs before the artifact gate, so a session that finalized its spec and
+ then failed a non-fixable check (a `baseline_revision` mismatch is the canonical trigger) left
+ its ledger entry behind, describing code the rollback had just discarded. The reset alone does
+ not remove it: the ledger sits under a protected artifact folder, which `_safe_reset`'s `keep`
+ shields from the untracked-file cleanup. The dev phase now snapshots the ledger before the
+ harvest and restores it around the rollback — unlinking the file when the harvest created it,
+ and on the stop-and-wait path too, which raises. Lossless, because the spec's `deferred:`
+ frontmatter is never mutated and the next attempt re-harvests from it. The snapshot is taken
+ ahead of every engine-side ledger write in that window rather than immediately ahead of the
+ harvest, so one growing inside the status reconcile or the state sync is covered by
+ construction. And it is persisted with the attempt rather than held in a local, so a
+ host death between the harvest and the rollback no longer loses it: the replayed attempt
+ writes back the same bytes the live path would have. That replaces a presence bit which could
+ only say whether a ledger existed at the attempt baseline — enough to delete one the harvest
+ had created, never enough to restore one that was already there, so a pre-existing _untracked_
+ or _gitignored_ ledger kept the dead attempt's finding for good. Nothing else reverts one:
+ `reset --hard` skips ignored paths, there is no `git clean`, and the artifacts dir is
+ `keep`-shielded besides. The restore never unlinks a ledger git tracks — that one is the reset's
+ to restore, and deleting it would hand the next attempt's `git add -A` a deletion to commit. The
+ snapshot is armed at the attempt's pre-harvest save and cleared once that attempt's decision is
+ acted on, so a finished story carries no copy of the ledger in `state.json`. The clear on the
+ PROCEED path persists immediately, because a hard kill there resumes straight into the review
+ leg and would otherwise strand that copy on a task that is already terminal; the retry site
+ deliberately does not, since the same kill replays that attempt and it still wants the
+ snapshot — except when that retry pauses, which the next bullet covers. A defer still
+ keeps its harvested entries: on the in-place path `_stash_deferred_artifacts` has already moved
+ the spec out of the artifacts dir, so the ledger entry is the finding's only surviving record.
+
+- **The pre-harvest snapshot is spent by a stop-and-wait pause instead of riding it (#405).**
+ `rollback_on_failure = off` is the default and does not roll back: it hands the tree to a human
+ and raises. The window that opens is not a crash window but a human-scale one — the operator
+ reads the notice, inspects the tree, and may well append to the deferred-work ledger before
+ typing `resume`. The armed snapshot used to survive that wait in `state.json`, and the resume,
+ which replays the same attempt, wrote those stale bytes back over whatever the operator had
+ done: an unlink when no ledger existed at the attempt baseline, and otherwise an overwrite,
+ which has no tracked-file guard, so a committed ledger was rewritten too. Nothing spent the arm
+ either, so it happened again on every subsequent resume. The pause leg now disarms in the same
+ `finally` that restores, strictly after it, and persists that immediately — the point is the
+ value the resume reads off disk. The replayed attempt then re-arms from the tree as the
+ operator left it, so its own harvest has something to revert to if it fails in turn. Replays
+ that never reached the arm re-arm the same way rather than proceeding blind, which retires the
+ `ledger-snapshot-missing` journal line for that case.
+
+- **The harvest's own ledger write is no longer the session's proof of work (#405).** The harvest
+ runs four statements above the dev artifact gate, and that gate deliberately does not exclude
+ the ledger — a story whose whole authorized scope is ledger reconciliation has to register as
+ real work. So the gate could not tell the orchestrator's write from the session's: a session
+ that finalized its spec, changed no code and recorded one `deferred:` finding proceeded to done
+ on the strength of the line the engine had just written for it. The gate now excludes the ledger
+ on exactly the attempts whose harvest filed into it, keyed on a flag persisted with the attempt
+ rather than re-derived — a crash replay re-runs the harvest, which dedupes against the dead
+ attempt's entries and so reports filing nothing while those entries are still in the tree.
+ Narrow by construction: a session's own ledger edit still counts as work on every attempt that
+ did not harvest, and the flag is cleared per attempt, so a later attempt is never charged for an
+ earlier one's harvest. All three dev legs pass it through — sprint story, sweep bundle, and
+ folder+id stories. The bundle leg is the costlier one, since an attempt let through there marks
+ real ids `done` and `open_ids` re-bundles only `open` entries.
+
+- **A sweep bundle's ledger closes are withheld until its attempt is accepted (#405).** The
+ orchestrator marks a bundle's deferred-work ids `done` itself, and did so above the artifact
+ gate — so an attempt that finalized its spec and then failed a non-fixable check was discarded
+ with the ledger already claiming its work resolved. A surviving close is worse than a surviving
+ finding: `open_ids` only ever re-bundles open entries, and the one reopen — `mark_open`, which
+ undoes only a close its own caller wrote — is not a general one, so no
+ later sweep looks at that id again and the work is silently lost rather than mis-recorded. On
+ the retry leg the dev phase's ledger snapshot reverted it; on the DEFER terminus nothing in the
+ dev phase could, because `_defer` takes its own snapshot after the close and writes it back
+ over its own reset — deliberately, so review-found deferrals survive a defer. The close now
+ runs below the gate, for a PROCEED decision only, which also excludes a CRITICAL escalation
+ (it preempts even a passing outcome) and a failing `[verify] commands` run. What remains after
+ acceptance is covered by the bullet below rather than by a revert. One deliberate consequence: that write no longer counts toward the gate's
+ proof-of-work diff, so a bundle session which changed no code now fails the gate instead of
+ passing on the orchestrator's own bookkeeping.
+
+- **A review-leg defer takes the bundle's ledger closes back (#405).** The bullet above stops a
+ DISCARDED dev attempt from closing anything; a bundle accepted at dev that then defers in
+ review is a different shape. There the closes were correct when written and are required —
+ `verify_review_bundle` gates on them — and only the later failure makes them wrong. `_defer`
+ then writes its own post-close ledger snapshot back over the `reset --hard` that had reverted
+ them, deliberately, since a harvested finding's ledger entry is its only surviving record once
+ the spec is stashed. But the restore replays the whole file, so the closes rode it too and
+ named code that no longer existed — on every route into a post-acceptance defer: review budget
+ exhausted, the repair phase exhausted after a clean review, the budget rescue's own verify
+ failing, `review.enabled = false` failing at its gate, and a blocking workflow deferring from
+ `post_dev_phase` / `post_review_result` / `pre_commit_gate`. The defer now re-opens the ids it
+ closed itself. `deferredwork.mark_open` is the undo of one specific `mark_done`, not a general
+ reopen: it refuses any entry that is not closed carrying exactly the resolution note the caller
+ wrote, so a close from an earlier sweep, from the legacy path where the session edits the
+ ledger, or from a human is never revoked — and the round trip restores what the close changed,
+ character for character. Unchanged on the
+ stop-and-wait path (`rollback_on_failure = off` keeps the tree, so there is nothing to undo)
+ and under worktree isolation (those closes live in the unit's own worktree, dropped unmerged).
+
+- **Three small contracts brought back into line (#405).** A ledger entry filed with no file:line
+ now carries `location: n/a` rather than no `location:` line: `deferred-work-format.md` has
+ always specified that value, and of the fields an entry is created with, `severity:` is the
+ only one it calls optional — so the orchestrator's own writer was the one producer disagreeing
+ with the format the sweep skill reads. The harvest's 200-character clamp can land on a word
+ break; the value is now stripped where it is produced rather than where it is written, so the
+ `origin:` dedup key and the `location:` line stay derived from the same string. Entries already
+ on disk without the line stay valid — read an absent `location:` as `n/a`. A plugin workflow's
+ completion-marker filename is now resolved on the skill tree of the adapter its own `role` runs
+ on, not always the dev one, so a run mixing `.claude/skills` and `.agents/skills` at different
+ upstream eras no longer spells a review session's marker after a primitive only the dev tree
+ carries. That one is the two halves agreeing rather than a repair: read-back was never at risk,
+ because both prefixes are matched unconditionally. And `diagnose` now pseudonymizes the `spec`
+ journal field, which is the customer's feature name and was shipping verbatim — the egress
+ backstop could only ever rescue the ones that happened to embed the story key. The value is
+ reduced to its basename first: the harvest kinds journal a bare filename while the reconcile
+ kinds journal an absolute path, so aliasing it raw would have given one spec two aliases in a
+ dump and parked a home path in the local `--legend` file.
+
## [0.9.0] — 2026-07-21
### Added
@@ -1691,6 +2069,7 @@ enforced in CI.
implementation phase, driven by a Python control loop with hook-based session transport and
resumable on-disk run state.
+[0.9.1]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.9.1
[0.9.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.9.0
[0.8.1]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.8.1
[0.8.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.8.0
diff --git a/README.md b/README.md
index a836fb72..84a8a197 100644
--- a/README.md
+++ b/README.md
@@ -443,7 +443,7 @@ Merge-back is always **serialized** — `max_parallel` is a validated knob clamp
-For a monorepo or any layout where the git root differs from the project dir, set an optional `repo_root` key in `_bmad/bmm/config.yaml` — it decouples where git/code work happens from where run state lives (defaults to the project dir).
+For a monorepo or any layout where the git root differs from the project dir, set an optional `repo_root` key in `_bmad/bmm/config.yaml` — it decouples where git/code work happens from where run state lives (defaults to the project dir). It is **not compatible with `isolation = "worktree"`**: provisioning seeds a worktree from `repo_root` while the preflight probes `project`, so `validate` reports the pair and `run`/`sweep`/`resume` refuse to start. Use one or the other on 0.9.x — plumbing both through provisioning is tracked as #414.
### Plugins
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 844c4ca4..d5889c08 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -70,7 +70,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (`keep_failed`, default on) and its full diff is preserved to `run_dir/failed//changes.patch`; `failed_diff_max_mb` caps per-file untracked-file size (oversized skipped with a marker), `failed_diff_unlimited` lifts the cap.
- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent, seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A`.
- Run state never moves into a worktree — `.bmad-loop/` always lives in the main repo; spec paths are persisted relative to the worktree so a kept-failed run stays portable.
-- Merge-back is serialized; `max_parallel` is a validated knob clamped to `1` until parallel fan-out is built. The `repo_root` key in `_bmad/bmm/config.yaml` (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos).
+- Merge-back is serialized; `max_parallel` is a validated knob clamped to `1` until parallel fan-out is built. The `repo_root` key in `_bmad/bmm/config.yaml` (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos) — but it is **mutually exclusive with `isolation = "worktree"`**, which seeds from `repo_root` while the preflight probes `project`; `validate` reports the pair and the run/sweep/resume preflight refuses it (#414).
- `commit_message_template` (`{story_key}` / `{run_id}` substituted) customizes story/bundle commit messages.
### Plugins (extensibility)
diff --git a/module.yaml b/module.yaml
index 2ec859a3..92960ed2 100644
--- a/module.yaml
+++ b/module.yaml
@@ -1,7 +1,7 @@
code: bmad-loop
name: BMAD Loop Skills
description: "Automation-mode skills driven by the bmad-loop orchestrator: interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill"
-module_version: 0.9.0
+module_version: 0.9.1
default_selected: false
module_greeting: >
BMAD Loop installed — both the automation skills and the
diff --git a/pyproject.toml b/pyproject.toml
index be0cd846..8913aad1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bmad-loop"
-version = "0.9.0"
+version = "0.9.1"
description = "Deterministic ralph-loop orchestrator for the BMAD implementation phase"
readme = "README.md"
license = "MIT"
diff --git a/src/bmad_loop/__init__.py b/src/bmad_loop/__init__.py
index f54f5a85..25866bf4 100644
--- a/src/bmad_loop/__init__.py
+++ b/src/bmad_loop/__init__.py
@@ -6,4 +6,4 @@
spec files, and the per-run directory under .bmad-loop/runs/.
"""
-__version__ = "0.9.0"
+__version__ = "0.9.1"
diff --git a/src/bmad_loop/bmadconfig.py b/src/bmad_loop/bmadconfig.py
index a29b49e3..279e429a 100644
--- a/src/bmad_loop/bmadconfig.py
+++ b/src/bmad_loop/bmadconfig.py
@@ -63,6 +63,50 @@ def rebase(p: Path) -> Path:
)
+def worktree_isolation_conflict(paths: ProjectPaths, isolation: str) -> str | None:
+ """The refusal message for ``isolation = "worktree"`` under a `repo_root`
+ override, or None when the combination is supported (#414).
+
+ Worktree provisioning reads ``repo_root`` for every surface it seeds *off disk*
+ — the upstream skill trees, `_bmad/` and the `_bmad/custom/` overrides inside
+ it, and each `seed_files`/`seed_globs` entry — and bakes the absolute hook-relay
+ path from it into the worktree's hook config, while `init`, `validate` and the
+ run preflight write and probe those same surfaces under ``project``. (The relay
+ itself is pointed at, never copied. The `MODULE_SKILLS` this wheel bundles are
+ seeded from package data and are unaffected by either root; nothing is seeded
+ from ``project``, which `provision_worktree` is never even passed.)
+ `load_paths` *requires* `project/_bmad/bmm/config.yaml`, so `_bmad/` is under
+ `project` by definition and `repo_root/_bmad/` generally does not exist. When
+ the two diverge the preflight therefore approves a surface the isolated run
+ never receives, and the seed-completeness gates go inert rather than fire: an
+ isolated session dispatches into a worktree with no dev primitive and no
+ renderer, and stops with no result and nothing journaled naming the cause.
+
+ Refusing is the whole fix here — the alternative (plumb `project` through
+ provisioning for the non-git reads) is a behaviour change this patch release
+ does not make, so the message names only remediations that exist today. Both
+ are named because either alone is sufficient and which one is right is the
+ operator's call: the override buys a decoupled git root, the isolation mode
+ buys per-unit worktrees, and 0.9.x cannot give both.
+
+ Sole producer of the text, shared by `cmd_validate`, the run/sweep preflight,
+ the dry-run honesty banner and the TUI's pre-launch guard, so the four cannot
+ drift. Compares resolved paths: `load_paths` resolves both sides, but a
+ hand-built :class:`ProjectPaths` (tests) need not have."""
+ if isolation != "worktree":
+ return None
+ if paths.repo_root.resolve() == paths.project.resolve():
+ return None
+ return (
+ 'isolation = "worktree" is not supported when repo_root differs from the project '
+ f"directory: worktree provisioning seeds from repo_root ({paths.repo_root}) while "
+ f"init, validate and the run preflight read the project ({paths.project}), so an "
+ "isolated session would get none of the skills the preflight just approved. "
+ "Remove the `repo_root` key from _bmad/bmm/config.yaml, or set "
+ '`isolation = "none"` under [scm] in .bmad-loop/policy.toml.'
+ )
+
+
def _resolve(raw: str, project: Path) -> Path:
return Path(raw.replace("{project-root}", str(project))).resolve()
@@ -73,7 +117,14 @@ def load_paths(project: Path) -> ProjectPaths:
if not config_path.is_file():
raise BmadConfigError(f"BMAD config not found: {config_path} (is BMAD installed here?)")
try:
- doc = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ # UnicodeDecodeError is a ValueError, not an OSError, so an undecodable file
+ # would otherwise escape every caller's `except BmadConfigError` and crash
+ # them. Same reasoning as `policy.load`.
+ raw = config_path.read_text(encoding="utf-8")
+ except UnicodeDecodeError as e:
+ raise BmadConfigError(f"{config_path} is not valid UTF-8: {e}") from e
+ try:
+ doc = yaml.safe_load(raw) or {}
except yaml.YAMLError as e:
raise BmadConfigError(f"invalid YAML in {config_path}: {e}") from e
diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py
index 739841bb..576d8b6f 100644
--- a/src/bmad_loop/checks.py
+++ b/src/bmad_loop/checks.py
@@ -39,6 +39,7 @@
"bmad-config",
"policy",
"policy.model-qualified",
+ "policy.isolation-repo-root",
"adapter.profile",
"adapter.binary",
"adapter.hookless",
@@ -48,6 +49,7 @@
"queue.stories-manifest",
"git.worktree-clean",
"git.probe",
+ "git.render-tracked",
"hooks.config-parse",
"hooks.registered",
"mux.backend",
@@ -59,6 +61,11 @@
"skills.base",
"skills.base-missing",
"skills.base-incomplete",
+ "skills.base-shim",
+ "skills.dev-renderer",
+ "skills.dev-renderer-config",
+ "skills.dev-renderer-sources",
+ "skills.customize-legacy",
"skills.stories-dispatch",
"skills.stories-dispatch-missing",
"skills.stories-dispatch-stale",
diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py
index e2dcba22..ee7dd967 100644
--- a/src/bmad_loop/cli.py
+++ b/src/bmad_loop/cli.py
@@ -107,6 +107,29 @@ def _reject_bad_run_id(run_id: str | None) -> int | None:
return None
+def _reject_isolation_conflict(paths: bmadconfig.ProjectPaths, pol) -> int | None:
+ """Refuse `isolation = "worktree"` under a `repo_root` override (#414). Returns
+ 1 to abort, None to proceed — the `_reject_bad_run_id` shape.
+
+ Called from the three :class:`~engine.Engine` construction sites that return an
+ rc to a human: `cmd_run`, `cmd_sweep`, and `_resume_paused_run` — the shared
+ helper behind both `resume` and `resolve`'s re-arm. The fourth such site, the
+ auto-triggered child sweep in `_sweep_factory`, shares the refusal but not this
+ disposition: it has no rc channel, so it raises (see the comment there).
+ Keyed on Engine construction rather than on "loads policy.toml", which is a
+ wider set that does not all provision — `_configure_mux` reads the file on
+ every command and builds nothing; `cmd_validate` and `cmd_clean` load it and
+ never mount a worktree.
+
+ `validate` deliberately does not call this — it reports rather than aborts, so
+ it renders the same message as a Finding and keeps running its other gates."""
+ conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)
+ if conflict is None:
+ return None
+ print(conflict, file=sys.stderr)
+ return 1
+
+
def _reconcile_stale(project: Path, paths: bmadconfig.ProjectPaths, pol) -> None:
"""Tear down worktrees leaked by a prior run that stopped mid-flight, before
starting a new run/sweep — the clean-finish GC never reached them. Gated on
@@ -119,6 +142,10 @@ def _reconcile_stale(project: Path, paths: bmadconfig.ProjectPaths, pol) -> None
print(f"reclaimed {len(freed)} stale worktree(s) from prior runs")
+# Every adapter role the orchestrator constructs. NOT the set the skills preflight
+# gates — that is install.DEV_PRIMITIVE_ROLES (dev+review), because triage's only
+# prompt ships in this wheel. Widening a skill probe back to ROLES re-breaks a
+# triage-only third CLI; see _skill_trees.
ROLES = ("dev", "review", "triage")
@@ -384,12 +411,32 @@ def cmd_validate(args: argparse.Namespace) -> int:
except policy_mod.PolicyError as e:
report.fail("policy", str(e))
+ # #414: the one configuration where every gate below reports on a surface the
+ # isolated run will never see. Reported only when it fires — there is no `ok`
+ # twin, because the supported case is "no such conflict", which would print a
+ # line about a coupling most projects have never configured either half of.
+ # Needs both halves loaded; either failing already has its own finding above.
+ if paths is not None and pol is not None:
+ conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)
+ if conflict is not None:
+ report.fail(
+ "policy.isolation-repo-root",
+ conflict,
+ {"repo_root": str(paths.repo_root), "project": str(paths.project)},
+ )
+
+ # Built exactly the way run/sweep's real preflight builds it, so validate's verdict
+ # and their abort cannot disagree. Deliberately NOT `[p.skill_tree for p in
+ # profiles]`: that carries triage's tree, and every skills check below asks a
+ # dev-primitive question. The `pol is not None` guard is load-bearing — `resolved`
+ # never raises, so the except above fires only on a policy that failed to load, and
+ # an unguarded call would crash validate instead of reporting the parse failure.
+ dev_trees = _skill_trees(project, pol) if pol is not None else []
+
stories_on, spec_folder = _stories_mode(args, pol)
if paths:
if stories_on:
- _validate_stories_queue(
- project, paths, spec_folder, [p.skill_tree for p in profiles], report
- )
+ _validate_stories_queue(project, paths, spec_folder, dev_trees, report)
else:
try:
ss = sprintstatus.load(paths.sprint_status)
@@ -418,6 +465,32 @@ def cmd_validate(args: argparse.Namespace) -> int:
except verify.GitError as e:
report.fail("git.probe", f"git check failed: {e}")
+ # #409: a tracked path ignores .gitignore and info/exclude entirely, so the two
+ # shields 0.9.1 added only help projects that had not already committed the
+ # renderer's output. Those that did keep churning it into every story commit —
+ # a new snapshot dir per machine, per checkout path and per renderer bump, since
+ # the path is keyed on a hash of the absolute project root plus a generation
+ # hash over the renderer, its sources and the resolved config. Probed at
+ # `project`, which is the root the renderer publishes under whenever it and
+ # `repo_root` coincide, and the one the gate above already reads.
+ # Warning, not a problem: nothing about a tracked _bmad/render/ stops a session.
+ try:
+ if verify.path_tracked(project, install.RENDER_DIR_REL):
+ report.warn(
+ "git.render-tracked",
+ f"{install.RENDER_DIR_REL}/ is tracked by git; run "
+ f"`git rm -r --cached {install.RENDER_DIR_REL}` and commit once to stop "
+ "committing rendered skill output",
+ {"path": install.RENDER_DIR_REL},
+ )
+ except verify.GitError:
+ # Not a repo, or git failed — say nothing rather than fabricate an `ok`.
+ # Deliberately not also catching OSError: a missing git binary raises out of
+ # `worktree_clean` above, which does not catch it either, so a second and
+ # differently-shaped degrade here would only imply a robustness validate
+ # does not have.
+ pass
+
report.extend(_platform_preflight())
for tool in dict.fromkeys(p.binary for p in profiles):
@@ -492,14 +565,33 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"role": role, "model": cfg.model, "profile": prof.name},
)
- base_problems = install.missing_base_skills(project, [p.skill_tree for p in profiles])
- if profiles and not base_problems:
+ base_problems = install.missing_base_skills(project, dev_trees)
+ # Gate on `dev_trees`, not `profiles`: policy never validates an adapter name (the
+ # first test is `get_profile`), so `[adapter] name = "nosuchcli"` beside a loadable
+ # `[adapter.triage]` leaves `profiles` non-empty while nothing dev-side resolved —
+ # and the ok line below would then be a green sentence assembled from an empty
+ # probe. Can only tighten: `dev_trees` truthy implies `profiles` truthy.
+ if dev_trees and not base_problems:
+ # Name the primitive that actually resolved, not a hardcoded era: on an
+ # upgraded project this is the operator's confirmation that the rename was
+ # picked up (and, across trees, that both picked up the same one).
+ resolved = list(
+ dict.fromkeys(
+ name
+ for tree in dict.fromkeys(dev_trees)
+ if (name := install.resolve_dev_primitive(project, tree)) is not None
+ )
+ )
report.ok(
"skills.base",
- "upstream skills present (bmad-dev-auto + review hunters)",
- {"trees": list(dict.fromkeys(p.skill_tree for p in profiles))},
+ f"upstream skills present ({' + '.join(resolved)} + review hunters)",
+ {
+ "trees": list(dict.fromkeys(dev_trees)),
+ "dev_primitive": resolved,
+ },
)
report.extend(base_problems)
+ report.extend(install.dev_primitive_warnings(project, dev_trees))
if getattr(args, "json", False):
# getattr, not args.json: cmd_validate is called directly by tests (and by
@@ -628,26 +720,90 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int:
return 0
-def _require_base_skills(project: Path, pol, *, require_stories: bool = False) -> bool:
- """Preflight the upstream skills the orchestrator drives (bmad-dev-auto + the
- three review hunters it invokes inline).
+def _skill_trees(project: Path, pol) -> list[str]:
+ """The skill trees this run's dev-primitive adapters read, one per distinct name.
- Returns True when everything is in place; otherwise prints the problems and
- returns False so the caller can abort before spawning any session (a missing
- skill would otherwise stall as an `Unknown command` until the run times out).
+ Shared by the real preflight, the dry-run banner and `cmd_validate` so the
+ three cannot drift: a preview that claims "run would abort" has to key on
+ exactly what makes run abort, and validate's verdict has to key on the same
+ thing again. Profiles that fail to load are skipped rather than raising — an
+ unknown adapter name is the policy loader's problem, not the skill probe's.
- ``require_stories`` additionally content-probes bmad-dev-auto for folder+id
- dispatch — stories mode needs a newer skill than sprint mode, so an older
- install must fail loudly here rather than HALT `no stories.yaml`-style at
- dispatch time."""
+ Scoped to :data:`install.DEV_PRIMITIVE_ROLES`, not :data:`ROLES`: every skill
+ these trees are asked about is one only a dev or review session dispatches, and
+ triage's whole prompt surface ships in this wheel. It is also the set
+ `Engine._worktree_profiles` provisions, so what is gated and what is carried
+ into a worktree stay one decision."""
from .adapters.profile import ProfileError, get_profile
- skill_trees = []
- for name in dict.fromkeys(pol.adapter.resolved(role).name for role in ROLES):
+ trees = []
+ for name in dict.fromkeys(
+ pol.adapter.resolved(role).name for role in install.DEV_PRIMITIVE_ROLES
+ ):
try:
- skill_trees.append(get_profile(name, project).skill_tree)
+ trees.append(get_profile(name, project).skill_tree)
except ProfileError:
continue
+ return trees
+
+
+def _warn_preflight_would_abort(
+ paths: bmadconfig.ProjectPaths, pol, *, require_stories: bool = False
+) -> None:
+ """Dry-run honesty banner: say so when the real command would refuse to run.
+
+ ``--dry-run`` returns before `_require_base_skills` (cmd_run/cmd_sweep), so a
+ project whose skills are broken still gets a plausible-looking preview. Since
+ the upstream rename that preview is actively misleading rather than merely
+ incomplete: the forwarding shim IS a valid slash command, so a previewed
+ ``/bmad-dev-auto`` reads fine and would HALT an unattended session on the
+ shim's interactive migration gate.
+
+ Takes the whole :class:`~bmadconfig.ProjectPaths` rather than `project` alone
+ because the #414 refusal is a fact about the two roots' relationship; every
+ other probe here still reads `paths.project`, which is what `init` wrote and
+ what a session's own root resolution will re-derive.
+
+ The exit code deliberately stays 0. A dry-run is a diagnostic — refusing to
+ print the schedule would withhold the very thing the operator asked for, and
+ every existing caller reads rc 0 as "the preview rendered", not as "the
+ project is ready". The banner goes to stderr so stdout stays the preview."""
+ trees = _skill_trees(paths.project, pol)
+ problems = [
+ p.message
+ for p in install.missing_base_skills(paths.project, trees)
+ + (install.missing_stories_support(paths.project, trees) if require_stories else [])
+ ]
+ conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)
+ if conflict is not None:
+ problems.insert(0, conflict)
+ if not problems:
+ return
+ print(
+ "note: this preview is NOT runnable as-is — the real command aborts at preflight:",
+ file=sys.stderr,
+ )
+ for problem in problems:
+ print(f" FAIL: {problem}", file=sys.stderr)
+ print("run `bmad-loop validate` for details", file=sys.stderr)
+
+
+def _require_base_skills(project: Path, pol, *, require_stories: bool = False) -> bool:
+ """Preflight the upstream skills the orchestrator drives (the dev primitive —
+ bmad-build-auto, or a complete pre-rename bmad-dev-auto — plus the three review
+ hunters it invokes inline).
+
+ Returns True when everything is in place; otherwise prints the problems and
+ returns False so the caller can abort before spawning any session (a missing
+ skill would otherwise stall as an `Unknown command` until the run times out).
+ A post-rename install left with only the forwarding shim fails here too: the
+ shim's interactive migration gate would HALT the session with nothing written.
+
+ ``require_stories`` additionally content-probes the resolved primitive for
+ folder+id dispatch — stories mode needs a newer skill than sprint mode, so an
+ older install must fail loudly here rather than HALT `no stories.yaml`-style at
+ dispatch time."""
+ skill_trees = _skill_trees(project, pol)
problems = install.missing_base_skills(project, skill_trees)
if require_stories:
problems += install.missing_stories_support(project, skill_trees)
@@ -710,10 +866,14 @@ def _validate_stories_queue(
report: ValidationReport,
) -> None:
"""Stories-mode counterpart of ``cmd_validate``'s sprint-status gate: validate
- the ``stories.yaml`` manifest + ``SPEC.md`` and confirm the installed
- ``bmad-dev-auto`` carries the folder+id dispatch flow stories mode needs (an
- older skill would HALT at dispatch). Appends findings to ``report`` in place;
- the probe carries its own remediation text ("update the bmm module")."""
+ the ``stories.yaml`` manifest + ``SPEC.md`` and confirm the installed dev
+ primitive carries the folder+id dispatch flow stories mode needs (an older
+ skill would HALT at dispatch). Appends findings to ``report`` in place;
+ the probe carries its own remediation text ("update the bmm module").
+
+ ``skill_trees`` is the dev+review subset :func:`_skill_trees` returns, not every
+ profile's tree: dispatch is a dev-primitive question, so a triage-only tree has
+ no say in it."""
folder = stories_mod.resolve_spec_folder(paths.project, spec_folder)
problem = _validate_stories_folder(paths, spec_folder)
if problem:
@@ -734,10 +894,16 @@ def _validate_stories_queue(
)
stories_probs = install.missing_stories_support(project, skill_trees)
if skill_trees and not stories_probs:
+ probed = list(
+ dict.fromkeys(
+ install.dev_primitive_or_default(project, tree)
+ for tree in dict.fromkeys(skill_trees)
+ )
+ )
report.ok(
"skills.stories-dispatch",
- "bmad-dev-auto supports folder+id dispatch (stories mode)",
- {"trees": list(dict.fromkeys(skill_trees))},
+ f"{' + '.join(probed)} supports folder+id dispatch (stories mode)",
+ {"trees": list(dict.fromkeys(skill_trees)), "dev_primitive": probed},
)
report.extend(stories_probs)
@@ -775,6 +941,14 @@ def cmd_run(args: argparse.Namespace) -> int:
if args.dry_run:
return _dry_run(paths, pol, args, stories_on, spec_folder)
+ # First of the configuration refusals (`_reject_bad_run_id` and the two loaders
+ # above can abort earlier), and deliberately before the queue and worktree-clean
+ # gates: this one says the configuration cannot run at all, so making the
+ # operator clear a dirty tree or fix a story key first would only delay the
+ # same abort.
+ if (rc := _reject_isolation_conflict(paths, pol)) is not None:
+ return rc
+
if stories_on:
problem = _validate_stories_folder(paths, spec_folder, selector=args.story)
if problem:
@@ -845,6 +1019,22 @@ def cmd_run(args: argparse.Namespace) -> int:
return 0
+def _dev_skill_for_role(pol, project: Path, role: str) -> str:
+ """The dev-primitive skill name ``role``'s adapter would invoke, for dry-run
+ previews. Mirrors `_require_base_skills`' profile→skill_tree lookup so the
+ preview and the real dispatch (``Engine._dev_skill``) resolve identically —
+ a pre-rename project previews ``/bmad-dev-auto``, a post-rename one
+ ``/bmad-build-auto``. An unloadable profile falls back to the legacy name;
+ the run itself would fail preflight before ever dispatching."""
+ from .adapters.profile import ProfileError, get_profile
+
+ try:
+ tree = get_profile(pol.adapter.resolved(role).name, project).skill_tree
+ except ProfileError:
+ tree = None
+ return install.dev_primitive_or_default(project, tree)
+
+
def _render_invocation(pol, project: Path, role: str, prompt: str) -> str:
from .adapters.profile import get_profile
@@ -882,6 +1072,8 @@ def _dry_run(
if stories_on:
return _dry_run_stories(paths, pol, args, spec_folder)
+ _warn_preflight_would_abort(paths, pol)
+
def render(role: str, prompt: str) -> str:
return _render_invocation(pol, paths.project, role, prompt)
@@ -898,10 +1090,12 @@ def render(role: str, prompt: str) -> str:
print("no actionable stories")
return 0
print(f"would process {len(queue)} stories (gates={pol.gates.mode}):")
+ dev_skill = _dev_skill_for_role(pol, paths.project, "dev")
+ review_skill = _dev_skill_for_role(pol, paths.project, "review")
for story in queue:
print(f"\n {story.key} (epic {story.epic}, status {story.status})")
- print(f" dev: {render('dev', f'/bmad-dev-auto {story.key}')}")
- print(f" review: {render('review', '/bmad-dev-auto ')}")
+ print(f" dev: {render('dev', f'/{dev_skill} {story.key}')}")
+ print(f" review: {render('review', f'/{review_skill} ')}")
print(f" env: BMAD_LOOP_MODE=1 BMAD_LOOP_STORY_KEY={story.key}")
return 0
@@ -922,6 +1116,7 @@ def _dry_run_stories(
) -> int:
"""Print the linear stories-mode schedule (list order, checkpoints, live
on-disk state) — no topo waves, one story per line, spawns nothing."""
+ _warn_preflight_would_abort(paths, pol, require_stories=True)
folder = stories_mod.resolve_spec_folder(paths.project, spec_folder)
# The real dispatch always uses the project-relative folder (the engine
# relativizes it); render the identical string here so dry-run and run agree.
@@ -940,13 +1135,14 @@ def _dry_run_stories(
f"(gates={pol.gates.mode}){spec_ok}"
)
print("linear schedule (list order — no depends_on, strictly serial):")
+ dev_skill = _dev_skill_for_role(pol, paths.project, "dev")
for row in rows:
print(f"\n {row.position}. {row.id} ({row.label}){_checkpoint_badge(row)} {row.title}")
# A spec_checkpoint story whose plan is not yet on disk dispatches leg 1
# (Halt after planning + BMAD_LOOP_PLAN_HALT); mirror the real dispatch's
# markers so dry-run does not under-report what run would emit.
plan_halt = stories_mod.is_plan_halt_leg(row.spec_checkpoint, row.state)
- dispatch = f"/bmad-dev-auto Spec folder: {rel}. Story id: {row.id}."
+ dispatch = f"/{dev_skill} Spec folder: {rel}. Story id: {row.id}."
if plan_halt:
dispatch += " Halt after planning."
print(f" dev: {_render_invocation(pol, paths.project, 'dev', dispatch)}")
@@ -1040,6 +1236,18 @@ def _sweep_factory(project: Path, paths: bmadconfig.ProjectPaths):
def factory(trigger: str) -> None:
pol = policy_mod.load(_policy_path(project))
+ # Raise rather than return the rc the other three sites return. By the time
+ # the engine calls this it has already latched the trigger and journaled
+ # `sweep-auto-trigger`, and it reads a plain return as success — so a bare
+ # decline would be recorded as `sweep-auto-finished`, which `engine.py`
+ # defines as "a clean completion from the parent's perspective": a child
+ # sweep that ran and finished when none was ever launched. Raising lands on
+ # the same `sweep-auto-failed` + notify path the `load` above already takes
+ # on an unparseable policy.toml, which is the same kind of event — the
+ # config on disk changed under a run that had already started.
+ conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)
+ if conflict is not None:
+ raise RuntimeError(conflict)
_start_sweep(
project,
paths,
@@ -1063,6 +1271,9 @@ def cmd_sweep(args: argparse.Namespace) -> int:
if args.dry_run:
return _sweep_dry_run(paths, pol)
+ if (rc := _reject_isolation_conflict(paths, pol)) is not None:
+ return rc
+
if not verify.worktree_clean(paths.repo_root):
print("git worktree is not clean — commit or stash first", file=sys.stderr)
return 1
@@ -1087,6 +1298,7 @@ def cmd_sweep(args: argparse.Namespace) -> int:
def _sweep_dry_run(paths: bmadconfig.ProjectPaths, pol) -> int:
+ _warn_preflight_would_abort(paths, pol)
ledger = paths.deferred_work
if not ledger.is_file():
print(f"no deferred-work ledger at {ledger}")
@@ -1122,6 +1334,12 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int:
print(f"run {run_dir.name} already finished", file=sys.stderr)
return 1
pol = policy_mod.load(_policy_path(project))
+ # Resume re-reads config.yaml and policy.toml from disk, so it is a second
+ # entrypoint into the same engine and gets the same refusal — a run started
+ # before the override was added must not finish its remaining stories through
+ # provisioning the preflight would now refuse.
+ if (rc := _reject_isolation_conflict(paths, pol)) is not None:
+ return rc
if not _require_base_skills(project, pol, require_stories=state.source == "stories"):
return 1
journal = Journal(run_dir)
diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/assets/module.yaml b/src/bmad_loop/data/skills/bmad-loop-setup/assets/module.yaml
index 2ec859a3..92960ed2 100644
--- a/src/bmad_loop/data/skills/bmad-loop-setup/assets/module.yaml
+++ b/src/bmad_loop/data/skills/bmad-loop-setup/assets/module.yaml
@@ -1,7 +1,7 @@
code: bmad-loop
name: BMAD Loop Skills
description: "Automation-mode skills driven by the bmad-loop orchestrator: interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill"
-module_version: 0.9.0
+module_version: 0.9.1
default_selected: false
module_greeting: >
BMAD Loop installed — both the automation skills and the
diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
index 98685173..1a751ecf 100644
--- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
+++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
@@ -35,6 +35,19 @@ reason:
status: open
```
+`location:` is always written. Use `n/a` whenever there is nothing to open — a
+deferred goal, but equally a finding whose reporter simply recorded no place.
+The field says "no location was recorded", not "this item has none": a reader
+who finds `n/a` should fall back to `reason:`, which often names the file even
+when `location:` is empty. Entries written before this rule was enforced may
+omit the line entirely; read an absent `location:` as `n/a`, never as "not yet
+known".
+
+Entries the orchestrator files carry one extra line, `source_spec:`, directly
+after `origin:` — the spec the deferral was harvested from. It is half the
+dedupe key (with `origin:`), so never edit or drop it when you touch an entry;
+entries you write by hand do not need it.
+
`severity:` is optional — entries written before this field existed have none
and that is fine; readers must treat a missing or unrecognized value as
"unspecified". Use `critical` for correctness/security issues, `high` for
diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py
index 6c90aaf3..9c33a09d 100644
--- a/src/bmad_loop/deferredwork.py
+++ b/src/bmad_loop/deferredwork.py
@@ -103,6 +103,50 @@ def mark_done(path: Path, dw_id: str, date: str, note: str) -> bool:
return True
+_MARK_DONE_TAIL_RE = re.compile(r"\nresolution:[ \t]*(.*)$", re.MULTILINE)
+
+
+def mark_open(path: Path, dw_id: str, note: str) -> bool:
+ """Undo one specific `mark_done`: flip `status: done ` back to
+ `status: open` and delete the `resolution:` line that call inserted. Returns
+ False (no write) when there is nothing of that shape to undo.
+
+ Deliberately NOT a general reopen. The write is refused unless the entry is
+ closed AND the line directly below its status is exactly the ``resolution:``
+ ``note`` the caller passed, so a close recorded by another run, by the legacy
+ path where the session edits the ledger itself, or by a human is never revoked
+ by a caller that did not write it. The ledger has no field that could say who
+ reopened what after the fact, so the check has to happen here.
+
+ The round trip restores the text `mark_done` changed, character for character,
+ because that call writes its resolution on the line directly below the status and
+ this removes exactly that line — which is what lets a caller undo a close without
+ having snapshotted the file. Character for character as `read_text` sees it, NOT
+ byte for byte: `mark_done` rewrites the whole file through `write_text`, so a
+ ledger stored CRLF is normalised to the platform's line ending by the close and
+ the reopen cannot put that back. Pre-existing, and invisible to git on a repo with
+ the usual `text=auto` handling."""
+ if not path.is_file():
+ return False
+ text = path.read_text(encoding="utf-8")
+ entry = _find_entry(text, dw_id)
+ if entry is None or entry.open:
+ return False
+ status_m = STATUS_RE.search(entry.body)
+ if status_m is None:
+ # `parse_ledger` tolerates an entry with no status line (it reads as not
+ # open, hence not caught above), and this runs from `_defer` — where an
+ # AttributeError would replace a deferral with a crash.
+ return False
+ res_m = _MARK_DONE_TAIL_RE.match(entry.body, status_m.end())
+ if res_m is None or res_m.group(1).strip() != note:
+ return False
+ start = entry.span[0] + status_m.start()
+ end = entry.span[0] + res_m.end()
+ path.write_text(text[:start] + "status: open" + text[end:], encoding="utf-8")
+ return True
+
+
def append_decision(path: Path, dw_id: str, date: str, label: str, detail: str) -> bool:
"""Record a human decision on an entry without changing its status."""
if not path.is_file():
@@ -146,6 +190,7 @@ def append_entry(
reason: str,
status: str = "open",
severity: str | None = None,
+ location: str | None = None,
) -> str | None:
"""Append a new canonical `### DW-` entry numbered past the highest
existing DW id, returning the new id (e.g. "DW-42").
@@ -153,7 +198,34 @@ def append_entry(
Idempotent: returns None without writing when an open entry already carries
the same `origin:` marker and `source_spec:` — so re-running the same defer
(e.g. a second sweep of the same story) never duplicates the entry. Creates
- the ledger (and parent dir) if it does not yet exist."""
+ the ledger (and parent dir) if it does not yet exist.
+
+ `location` is the format's file:line/component field; it is written directly
+ after `source_spec:` so the emitted order matches the canonical
+ origin → location → severity → reason → status shape.
+
+ The line is written ALWAYS, with `n/a` standing in for an empty (or
+ whitespace-only) value, because that is what the format has always specified:
+ deferred-work-format.md gives `location:` as `` — unchanged in every revision of the file since the
+ repo's first commit — and migration-mode.md tells the migrating session to
+ write `n/a` when it can extract nothing. Omitting the line instead made this
+ writer the one producer disagreeing with its own spec. The reader that cares
+ is an LLM — the sweep skill's triage step ("Read its `location:`
+ (file/component) in the current tree") — and `n/a` tells it there is nothing
+ to open, where an absent line leaves it inferring that from a gap.
+
+ `severity:` keeps the opposite treatment on purpose: of the fields an entry
+ is CREATED with, it is the only one the format calls optional, and it says in
+ as many words that a missing one is fine. (`resolution:` and `decision:` are
+ also called optional, but they are sweep annotations added to an entry that
+ already exists, so this writer never emits them.)
+
+ Nothing in Python parses THIS field — `parse_ledger` extracts only the
+ heading and `status:`, and the one field the TUI pulls out of an entry body
+ is `severity:` (`tui/data.py`, for row colouring); the body itself reaches the
+ entry modal verbatim. So entries already on disk without the line stay valid,
+ and readers must treat an absent `location:` as `n/a`."""
text = path.read_text(encoding="utf-8") if path.is_file() else ""
for entry in parse_ledger(text):
if (
@@ -164,6 +236,7 @@ def append_entry(
return None
dw_id = f"DW-{next_seq(text)}"
lines = [f"### {dw_id}: {title}", f"origin: {origin}", f"source_spec: `{source_spec}`"]
+ lines.append(f"location: {(location or '').strip() or 'n/a'}")
if severity:
lines.append(f"severity: {severity}")
lines.append(f"reason: {reason}")
diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py
index 25e9e011..fb8cac90 100644
--- a/src/bmad_loop/devcontract.py
+++ b/src/bmad_loop/devcontract.py
@@ -20,11 +20,13 @@
from __future__ import annotations
+import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
+from . import deferredwork
from .verify import DEV_WORKFLOW, read_frontmatter
# The section the skill appends on EVERY terminal path (success and blocked),
@@ -80,9 +82,13 @@
)
# The skill's no-spec fallback artifact (HALT when {spec_file} is unknown/missing):
-# `{implementation_artifacts}/bmad-dev-auto-result-.md`. It
-# carries a terminal frontmatter `status:` but no `## Auto Run Result` heading.
-FALLBACK_RESULT_PREFIX = "bmad-dev-auto-result-"
+# `{implementation_artifacts}/-result-.md`. It carries a
+# terminal frontmatter `status:` but no `## Auto Run Result` heading. BOTH eras are
+# listed and matched unconditionally: the artifact is named after whichever skill
+# wrote it, and a run can read an artifact left by the other era (a resume across an
+# upstream upgrade, or a project mid-migration), so this must never be keyed on the
+# skill name resolved on disk today.
+FALLBACK_RESULT_PREFIXES = ("bmad-build-auto-result-", "bmad-dev-auto-result-")
@dataclass(frozen=True)
@@ -164,6 +170,121 @@ def parse_auto_run_result(text: str) -> AutoRunResult:
return AutoRunResult(present=True, status=status, detail=body.strip())
+# ------------------------------------------------ deferred review findings
+#
+# Since BMAD-METHOD #2640 the dev primitive records the findings its review
+# triaged as `defer` in the spec's OWN frontmatter (a `deferred:` list) instead
+# of appending them to `deferred-work.md`: the worker owns its spec artifact,
+# the orchestrator owns the human-facing ledger. Each item carries a required
+# `summary` plus an `evidence` line and optional `location` / `severity`,
+# serialized as YAML block scalars (`>-` / `|-`) so `:`, `#`, quotes and line
+# breaks stay data rather than structure.
+#
+# The harvest is keyed on the FIELD BEING PRESENT, never on which skill era is
+# installed on disk: a pre-#2640 primitive simply never writes the field (absent
+# → nothing to harvest, the flat `deferred-work.md` appender still applies), and
+# a spec left by a newer primitive is harvested even when today's resolved skill
+# is the older one — the case a resume across an upstream upgrade produces.
+DEFERRED_FIELD = "deferred"
+# Ledger field lines are single-line and human-scannable, and the summary becomes
+# a `### DW-: ` heading — so every harvested value is flattened to one
+# line and clamped. Clamping happens BEFORE fingerprinting so the identity of a
+# finding is exactly what the ledger can hold: a change past the clamp point
+# cannot mint a second entry that renders identically to the first.
+_SUMMARY_LIMIT = 200
+_EVIDENCE_LIMIT = 1000
+_LOCATION_LIMIT = 200
+
+
+@dataclass(frozen=True)
+class DeferredFinding:
+ """One well-formed `deferred:` item, flattened and clamped for the ledger.
+
+ ``evidence``/``location``/``severity`` are "" when the item omitted them (or
+ named a severity outside the ledger's vocabulary); ``summary`` is never "" —
+ an item without one is malformed, not a finding."""
+
+ summary: str
+ evidence: str
+ location: str
+ severity: str
+ fingerprint: str
+
+
+def harvest_fingerprint(*parts: str) -> str:
+ """Stable short identity for a harvested item, joined NUL-separated so no
+ part can impersonate a boundary. Not a credential — it is the dedup key the
+ ledger carries in its `origin:` line, which is why it must be derived only
+ from values the ledger itself preserves."""
+ return hashlib.sha1(
+ "\0".join(parts).encode("utf-8"),
+ usedforsecurity=False,
+ ).hexdigest()[:12]
+
+
+def _flatten(value: Any, limit: int) -> str:
+ """Collapse one frontmatter scalar to a single clamped line. YAML hands back
+ whatever type the block scalar produced (str, int, bool, None), so coerce
+ before splitting; None becomes "" rather than the string "None".
+
+ The clamp is applied to the joined line and the result stripped again,
+ because the cut can land on a join space and leave a trailing one — a line
+ that is not "collapsed" in the sense this function promises. Stripping HERE
+ rather than at each consumer is what keeps the two consumers agreeing:
+ `location` feeds both :func:`harvest_fingerprint` (the ledger's `origin:`
+ dedup key) and the ledger's own `location:` line, and a value cleaned on only
+ one of those paths makes the key underivable from the entry that carries it.
+ """
+ if value is None:
+ return ""
+ return " ".join(str(value).split())[:limit].strip()
+
+
+def parse_deferred_findings(fm: dict[str, Any]) -> tuple[list[DeferredFinding], list[str]]:
+ """Read a spec's frontmatter `deferred:` list into findings + malformed notes.
+
+ Absent, null, or `[]` → ``([], [])``: nothing was deferred, which is the
+ overwhelmingly common case and must be indistinguishable from a pre-#2640
+ spec. A non-list value, a non-mapping item, or an item with no usable
+ ``summary`` is reported in the second list (a one-line note per problem) and
+ its WELL-FORMED SIBLINGS are still harvested — a single mangled item must not
+ silently swallow the rest of the pass's deferred work.
+
+ Never raises: this runs on the observation path, over LLM-written YAML.
+ """
+ raw = fm.get(DEFERRED_FIELD)
+ if raw is None:
+ return [], []
+ if not isinstance(raw, list):
+ return [], [f"`{DEFERRED_FIELD}:` is not a list (got {type(raw).__name__})"]
+ findings: list[DeferredFinding] = []
+ malformed: list[str] = []
+ for i, item in enumerate(raw, start=1):
+ if not isinstance(item, dict):
+ malformed.append(f"item {i}: not a mapping (got {type(item).__name__})")
+ continue
+ summary = _flatten(item.get("summary"), _SUMMARY_LIMIT)
+ if not summary:
+ malformed.append(f"item {i}: no usable `summary`")
+ continue
+ location = _flatten(item.get("location"), _LOCATION_LIMIT)
+ findings.append(
+ DeferredFinding(
+ summary=summary,
+ evidence=_flatten(item.get("evidence"), _EVIDENCE_LIMIT),
+ location=location,
+ # An unrecognized (or absent) severity yields "" — the ledger
+ # format's documented "unspecified", written as no field line at
+ # all rather than as a token no reader knows.
+ severity=deferredwork.SEVERITY_ALIASES.get(
+ str(item.get("severity", "")).strip().lower(), ""
+ ),
+ fingerprint=harvest_fingerprint(summary, location),
+ )
+ )
+ return findings, malformed
+
+
@dataclass(frozen=True)
class SynthResult:
"""A synthesized result.json plus the cross-check signal. `result_json` is
@@ -287,8 +408,9 @@ def find_result_artifact(impl_artifacts: Path, *, since_ns: int) -> Path | None:
writes no result.json, so on the session's Stop event we locate the spec it
produced. The common case is a `spec-*.md` carrying a terminal `## Auto Run
Result` section (appended by the skill's HALT on success AND blocked, when a
- spec exists). The skill's no-spec fallback — `bmad-dev-auto-result-*.md`,
- written when intent was too unclear to even create a spec — carries a
+ spec exists). The skill's no-spec fallback — `bmad-build-auto-result-*.md`
+ (`bmad-dev-auto-result-*.md` pre-rename), written when intent was too unclear
+ to even create a spec — carries a
terminal frontmatter `status:` but NO `## Auto Run Result` heading, so it is
matched by filename instead. Scans `impl_artifacts` for the most-recently-
modified qualifying markdown modified at/after `since_ns` (the session launch
@@ -308,7 +430,7 @@ def find_result_artifact(impl_artifacts: Path, *, since_ns: int) -> Path | None:
# The no-spec fallback is recognized by name (it has no Auto Run Result
# heading); every other artifact must carry a real (non-fenced) terminal
# section — a fence-quoted example must not qualify the spec (#52).
- if not path.name.startswith(FALLBACK_RESULT_PREFIX):
+ if not path.name.startswith(FALLBACK_RESULT_PREFIXES):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py
index 6fc51647..15a46c82 100644
--- a/src/bmad_loop/diagnostics.py
+++ b/src/bmad_loop/diagnostics.py
@@ -34,6 +34,7 @@
import json
import platform
+import re
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -78,7 +79,52 @@
"target_branch": "branch",
"commit": "commit",
"baseline": "commit",
+ # A spec name IS the customer's feature name — `Pseudonymizer`'s own docstring
+ # has always listed "spec filenames" among what it exists to alias, so the
+ # omission here was a routing gap, not a policy. The harvest events pass a
+ # bare basename, which `looks_like_identifier` waves through verbatim on the
+ # scrub_json fallback — and the egress backstop cannot rescue it, because it
+ # only repairs values already in the legend, so it happens to catch
+ # `1.2-Acme….md` (the story key is in there) and misses `AcmeVaultRotation.md`
+ # entirely. Its OWN namespace, not "story": the epic lookup below is keyed on
+ # ns == "story", so a filename aliased there would render as an epic-less
+ # `story-` — indistinguishable from a story key whose epic could not be
+ # resolved. See `_JOURNAL_BASENAME_NAMESPACES` for why the value is
+ # normalized first: the producers do NOT agree on a bare basename.
+ "spec": "spec",
}
+# Namespaces whose journalled value arrives in more than one shape and must be
+# reduced to its basename before it is aliased. `spec` is one: engine.py's
+# harvest kinds journal `spec_path.name`, its reconcile kinds journal
+# `str(spec_path)` (absolute — `verify.resolve_spec_path` returns an absolute
+# path), and stories_engine journals `task.spec_file`. Aliasing the raw string
+# would give ONE spec TWO aliases in a single dump, defeating the correlation
+# these fields are aliased rather than dropped to preserve, and would park an
+# absolute home path in the local `--legend` file (before `spec` was routed such
+# a value died at `scrub_json` and never entered the map at all).
+#
+# Split on BOTH separators rather than using `PurePath(...).name`: a journal
+# written on Windows is routinely diagnosed on POSIX, where `PurePath` treats a
+# backslash as an ordinary character and would keep the whole path. The cost is
+# that a POSIX filename containing a literal backslash aliases on its tail —
+# a pathological name, and the consequence is a shorter legend entry, never a
+# leak, since the alias is still stable and the raw value still never ships.
+_JOURNAL_BASENAME_NAMESPACES = frozenset({"spec"})
+_PATH_SEP_RE = re.compile(r"[\\/]")
+
+
+def _alias_input(value: Any, ns: str) -> Any:
+ """The string an alias is computed over: the basename for the path-shaped
+ namespaces, the value unchanged for every other one (and for any non-string,
+ which :meth:`Pseudonymizer.alias` handles itself)."""
+ if ns not in _JOURNAL_BASENAME_NAMESPACES or not isinstance(value, str):
+ return value
+ # `or value`: a value ending in a separator splits to an empty tail, and an
+ # empty string is the one input `alias()` passes through unaliased — so it
+ # would render as `""` and lose the event's only reference to the spec.
+ return _PATH_SEP_RE.split(value)[-1] or value
+
+
# Journal fields that carry free text (LLM/merge prose, prompts, errors). Never
# emitted — replaced with a boolean presence marker so a maintainer still learns
# the field was set without seeing it.
@@ -361,6 +407,7 @@ def _scrub_entry(
out[k] = [pseudo.alias(x, ns=ns, epic=epic_by_key.get(str(x))) for x in v]
elif k in _JOURNAL_ALIAS_FIELDS:
ns = _JOURNAL_ALIAS_FIELDS[k]
+ v = _alias_input(v, ns)
epic = epic_by_key.get(str(v)) if ns == "story" else None
out[k] = pseudo.alias(v, ns=ns, epic=epic)
else:
diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py
index 690677f5..197ec1a2 100644
--- a/src/bmad_loop/engine.py
+++ b/src/bmad_loop/engine.py
@@ -30,7 +30,15 @@
decide_review_session,
preference_escalations,
)
-from .install import provision_worktree
+from .install import (
+ RENDERER_SCRIPT_MARKER,
+ RENDERER_SEED_SENTINELS,
+ base_skills_seed_incomplete,
+ dev_primitive_or_default,
+ provision_worktree,
+ renderer_stub_resolved,
+ worktree_seed_undelivered,
+)
from .journal import Journal, save_state
from .model import (
PAUSE_EPIC_BOUNDARY,
@@ -59,6 +67,13 @@
unit_worktrees_dir,
)
+# `origin:` marker prefix for ledger entries harvested out of a spec's
+# frontmatter `deferred:` list (BMAD-METHOD #2640). The full marker is
+# ` ` — matched verbatim on every later harvest, so it is
+# the dedup key and must never be reworded; `-malformed `
+# marks the aggregated unparseable-items meta-entry.
+HARVEST_ORIGIN = "spec-deferred"
+
class RunPaused(Exception):
def __init__(self, reason: str, stage: str, story_key: str | None = None):
@@ -144,8 +159,9 @@ def render(self) -> str:
# *infer* the completion-marker convention, and one that finishes its work but
# never writes the marker leaves the orchestrator waiting (a completion-signal
# livelock, bounded only by session_timeout_min). The orchestrator's adapter
-# discovers the marker by its `bmad-dev-auto-result-` filename prefix and
-# mtime, not by exact name.
+# discovers the marker by its `-result-` filename prefix and
+# mtime, not by exact name (devcontract.FALLBACK_RESULT_PREFIXES accepts both
+# the pre- and post-rename spellings, so either resolution reads back).
WORKFLOW_COMPLETION_CONTRACT = """
## Completion signal (required)
@@ -260,6 +276,11 @@ def __init__(
# best-effort hint (None when the estimate could not be computed).
self._graceful_stopped = False
self._graceful_remaining: int | None = None
+ # dev-primitive name resolved from disk, memoized per skill tree (see
+ # _dev_skill). Keyed by tree — one run can mix trees (dev=claude reads
+ # .claude/skills, review=codex reads .agents/skills) — with None for an
+ # adapter that carries no profile at all.
+ self._dev_skill_cache: dict[str | None, str] = {}
# ------------------------------------------------------------- top level
@@ -530,7 +551,12 @@ def _ensure_target_branch(self) -> None:
def _worktree_profiles(self):
"""The distinct CLI profiles of the dev + review adapters, for provisioning
their skills/hooks into a worktree. Adapters without a `profile` (e.g. test
- fakes) contribute nothing, so provisioning is a no-op for them."""
+ fakes) contribute nothing, so provisioning is a no-op for them.
+
+ Deliberately the same roles as :data:`install.DEV_PRIMITIVE_ROLES`, which is
+ what the skills preflight gates — one decision read from opposite ends. This
+ side stays keyed on the adapters actually constructed rather than on role
+ names, since a test fake has no profile to provision either way."""
seen: dict[str, object] = {}
for adapter in (self.adapters["dev"], self.adapters["review"]):
profile = getattr(adapter, "profile", None)
@@ -580,6 +606,18 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) ->
return
task.worktree_path = str(unit.path)
task.branch = unit.branch
+ # Journaled HERE — beside the state it mirrors, and above everything below that
+ # can pause the run — rather than after provisioning. `task.worktree_path` is
+ # already set, and both provisioning escalations below `_save()` it before they
+ # raise; the escalation prose promises the half-provisioned worktree "stays
+ # mounted for the operator to inspect", and the journal was the one place that
+ # never said where. It pairs with the `worktree-open-failed` line above it, so
+ # every mount attempt now leaves exactly one record either way. Nothing keys on
+ # it — no `src/` reader consumes this kind, and no consumer requires it to be
+ # last or to imply that provisioning succeeded.
+ self.journal.append(
+ "worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path)
+ )
# A worktree checks out tracked files only, but the bmad-loop-* skill
# trees + signal-hook config are typically gitignored, so they are absent
# from the fresh checkout. Re-lay them into the worktree so the bundled
@@ -598,12 +636,14 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) ->
# config so the worktree's Editor MCP is reachable. Aggregate every loaded
# plugin's declared seeds.
seeds.extend(self._registry.seed_files())
+ seed_files = list(dict.fromkeys(seeds)) # dedupe, preserve order
+ seed_globs = self._registry.seed_globs()
skipped_seeds = provision_worktree(
unit.path,
profiles,
self.paths.repo_root,
- seed_files=list(dict.fromkeys(seeds)), # dedupe, preserve order
- seed_globs=self._registry.seed_globs(),
+ seed_files=seed_files,
+ seed_globs=seed_globs,
)
if skipped_seeds:
# A seed entry whose destination already exists is a no-op. Harmless for
@@ -614,9 +654,129 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) ->
self.journal.append(
"worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds
)
- self.journal.append(
- "worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path)
+ # The OTHER half of the same silence, and a different fact (#415): a seed entry
+ # naming something the repo HAS that the worktree did not get. The seed loops
+ # drop one with a bare `continue` when the resolve-and-contain guard refuses it,
+ # and the canonical trigger is a config the repo carries as a symlink OUT of
+ # itself. Re-probed rather than read out of `skipped_seeds` — same reason as the
+ # skills gate below — and on its own kind, because "already there, your entry is
+ # doing nothing" and "the repo has it and this worktree does not" want different
+ # fixes.
+ #
+ # Journaled, never escalated, and that is the deliberate difference from the two
+ # gates below. Those name files the ORCHESTRATOR itself dispatches or the
+ # renderer HALTs on, so their absence is a determinate stall on every story. A
+ # seed entry is arbitrary user/plugin-declared config and bmad-loop cannot know
+ # whether the session needs it — while the trigger is an ordinary healthy setup
+ # (a dotfile-managed `.claude/settings.json`, a shared MCP config), so pausing
+ # would refuse every run of such a project over a guard doing its job.
+ #
+ # The hook configs are named separately because the step BELOW provisioning
+ # writes them itself, so their presence in the worktree proves nothing about
+ # the seed — and every one of them is also a profile seed entry, the sole one
+ # for gemini/copilot/antigravity. Hookless profiles are excluded here for the
+ # same reason the exclude patterns exclude them: they have no `config_path`,
+ # and their empty string would name the worktree root.
+ undelivered_seeds = worktree_seed_undelivered(
+ unit.path,
+ self.paths.repo_root,
+ seed_files=seed_files,
+ seed_globs=seed_globs,
+ config_paths=[p.hooks.config_path for p in profiles if not p.hookless],
)
+ if undelivered_seeds:
+ self.journal.append(
+ "worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds
+ )
+ # The skills half of the same fault, and it needs no RENDERER-era gate: a
+ # worktree missing the dev primitive or a review hunter — or any file inside one
+ # the repo carries — stalls the session whether that SKILL.md renders or is inline,
+ # and the seed reads the same repo on every story. It does carry a
+ # PRIMITIVE-era one, inside the predicate: the era this run will not dispatch is
+ # not a stall to pause over. Re-probed rather than
+ # read out of `skipped_seeds`, so a user `worktree_seed` entry spelling a skill
+ # rel cannot forge a pause. Checked BEFORE the renderer branch: a worktree with
+ # no dev primitive at all has nothing for a renderer surface to be short FOR,
+ # so naming the renderer there would send the operator to the wrong file.
+ absent_skills = base_skills_seed_incomplete(
+ unit.path, self.paths.repo_root, [p.skill_tree for p in profiles]
+ )
+ if absent_skills:
+ reason = (
+ f"the worktree is missing upstream skill files the repo has "
+ f"({', '.join(absent_skills)}) — the session would stall having "
+ f"written nothing: on `Unknown command` when the whole skill is "
+ f"absent, inside the workflow when the dir is there but short of a "
+ f"file the repo carries. The usual cause is a skill directory — or one "
+ f"file or sub-directory inside one — symlinked to a shared BMad install "
+ f"outside the repo, which worktree seeding cannot follow; a checked-out "
+ f"symlink whose target does not exist on this machine reads the same way"
+ )
+ task.phase = Phase.ESCALATED
+ self.journal.append("story-escalated", story_key=task.story_key, reason=reason)
+ gates.notify(
+ self.policy,
+ self.run_dir,
+ f"CRITICAL escalation: {task.story_key}",
+ f"{reason} — resolve, then `bmad-loop resume {self.state.run_id}`",
+ )
+ self._save()
+ raise RunPaused(reason, PAUSE_ESCALATION, task.story_key)
+ short_surface = [rel for rel in RENDERER_SEED_SENTINELS if rel in skipped_seeds]
+ if short_surface and renderer_stub_resolved(
+ self.paths.project, [p.skill_tree for p in profiles]
+ ):
+ # ...with exactly one exception, and only for a project whose sessions
+ # actually render: the worktree's copy of the renderer's required surface
+ # came up SHORT of the repo's — an incomplete `_bmad/scripts/`, an absent
+ # `_bmad/config.toml`, or both. A renderer stub would then run without
+ # `render_skill.py`/`config_utils.py`, or without the config layer
+ # `load_central_config` requires, and Stop having written nothing — on
+ # THIS story and, since the seed reads the same repo every time, on every
+ # story after it. That is the env-fault shape `VerifyOutcome.env_fault`
+ # names: no repair session can fix it and no other story escapes it, so
+ # pause the run instead of walking the whole backlog into it. Escalate
+ # rather than defer for the same reason.
+ #
+ # Intersect the sentinel tuple rather than testing one constant, and name
+ # what actually fired: the two legs have different remediations and the
+ # symlink that drops a config is not the symlink that drops a scripts dir.
+ #
+ # The renderer conjunct is what keeps this era-agnostic, and it is not
+ # optional: the provisioning-side checks can only see the REPO (they fire
+ # on any repo carrying a renderer-era `_bmad/scripts/` or a `config.toml`
+ # the seed cannot follow — a shared install wired as a symlink is the
+ # canonical shape), while whether that matters depends on the resolved
+ # primitive's content. On a pre-#2601 inline SKILL.md nothing reads either
+ # path, so the short seed costs the run nothing and stays an ordinary
+ # `worktree-seed-skipped` line. This is the worktree continuation of the
+ # `skills.dev-renderer` preflight, which asked the same content question of
+ # the main checkout and passed — only the worktree's copy is short. ANY
+ # over the dev+review trees, since resolution is per tree and either
+ # session kind needing the renderer is enough.
+ #
+ # Raised BEFORE the workspace swaps to the unit below, so the half-seeded
+ # worktree stays mounted for the operator to inspect — the same courtesy
+ # _run_isolated's docstring promises a RunPaused out of `drive`. The phase
+ # is set directly: PENDING has no legal move to ESCALATED (see the
+ # worktree-open-failed branch above, which sets DEFERRED the same way).
+ reason = (
+ f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but the "
+ f"worktree's renderer surface came up short of the repo's "
+ f"({', '.join(short_surface)}) — the session would HALT without "
+ f"writing a spec. The usual cause is a symlinked _bmad/ pointing "
+ f"outside the repo, which worktree seeding cannot follow"
+ )
+ task.phase = Phase.ESCALATED
+ self.journal.append("story-escalated", story_key=task.story_key, reason=reason)
+ gates.notify(
+ self.policy,
+ self.run_dir,
+ f"CRITICAL escalation: {task.story_key}",
+ f"{reason} — resolve, then `bmad-loop resume {self.state.run_id}`",
+ )
+ self._save()
+ raise RunPaused(reason, PAUSE_ESCALATION, task.story_key)
self._save()
prev = self.workspace
self.workspace = unit.workspace
@@ -1701,6 +1861,16 @@ def _drive_story(self, task: StoryTask, dev_resume: SessionResult | None = None)
self._review_and_commit(task)
def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None) -> bool:
+ if resume_result is None:
+ # Ledger-snapshot clear site 0, and deliberately ABOVE the veto check:
+ # `_vetoed` drives the task terminal and saves, so a veto below this
+ # would persist a dead task still carrying a copy of the ledger. A fresh
+ # (non-replayed) entry owes nothing to a previous invocation, and one
+ # real hole needs closing: a completed session record with `result_json
+ # is None` refuses replay (see `_resumable_session`), so the resume
+ # RESTARTS the story and re-enters here still carrying the dead
+ # invocation's armed snapshot. Disarming here makes that unreachable.
+ self._disarm_ledger_snapshot(task)
if self._vetoed(self._emit("pre_dev_phase", task), task):
return False
if resume_result is None:
@@ -1713,6 +1883,7 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None
task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root))
feedback: Path | None = None
while True:
+ replayed = resume_result is not None
if resume_result is None:
# a resumed result replays the attempt it was recorded under, so
# the counter (and the session task_id derived from it) must not
@@ -1746,6 +1917,76 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None
advance(task, Phase.DEV_VERIFY)
outcome = None
if result.status == "completed":
+ # Arm the pre-harvest ledger snapshot for the non-fixable-RETRY revert
+ # below. PERSISTED, with a `_save()`, so a host death anywhere between
+ # here and the rollback cannot lose it: the replayed attempt writes
+ # back the very bytes the live path would have.
+ #
+ # The position is the design. It is AFTER the session process ended
+ # (`_run_session` returns only once the adapter has reaped it), so
+ # anything the SESSION wrote to the ledger is inside the snapshot and
+ # survives the revert; and BEFORE every ENGINE-side ledger write of
+ # this attempt that the rollback has to undo — today that is
+ # `_harvest_spec_deferrals` alone, which files findings about work the
+ # rollback is about to discard. The boundary is drawn above
+ # `_reconcile_generic_terminal_status` and `_post_dev_state_sync` too,
+ # rather than immediately above the harvest, so a future ledger write
+ # growing inside either of them is covered by construction.
+ #
+ # The other engine-side ledger write — the sweep engine's `status:
+ # done` bundle closes — is deliberately NOT in this window: it sits
+ # below the artifact gate, in `_post_dev_accepted_sync`, so no
+ # snapshot of THIS phase's making has to cover it. (A blocking
+ # `post_dev_phase` workflow can still defer an accepted attempt after
+ # that close; `_reopen_ledger_after_defer` is what covers that, and
+ # every other post-acceptance defer.) See that method for why a
+ # snapshot here could never have covered the DEFER leg (#405).
+ #
+ # NOT pinned by a test, and said on measurement rather than
+ # assertion: with the sweep close moved out there is no engine-side
+ # ledger write left between the arm and `_harvest_spec_deferrals`, so
+ # sliding the arm down to just above the harvest leaves the whole
+ # suite green. The boundary is defensive positioning for the next
+ # write to grow inside either sync, not an observable property.
+ #
+ # NOT on a replayed attempt that still HAS a snapshot: the host died
+ # somewhere between the dead process's writes and its rollback, so what
+ # is on disk now is already post-harvest and re-arming would restore the
+ # very edits the rollback exists to undo. The dead attempt's persisted
+ # snapshot is what the replay restores instead.
+ #
+ # A replay with NOTHING armed is the opposite case and re-arms (#405).
+ # It means the previous pass through here already spent its snapshot —
+ # the `rollback_on_failure = off` pause disarms in its `finally`, so the
+ # ledger on disk is pre-harvest bytes plus whatever the operator wrote
+ # during the pause, which is exactly what this attempt should be able to
+ # revert to. Without the re-arm the resumed attempt runs its harvest with
+ # no snapshot at all and a second failure leaves that harvest behind.
+ # (The pre-existing `ledger-snapshot-missing` states are unaffected: they
+ # are replays that never reached the arm, and they still find nothing
+ # armed — but they now re-arm from disk rather than proceeding blind,
+ # which is strictly more recoverable.)
+ #
+ # Same site, same reason — but deliberately its OWN guard, and NOT
+ # the relaxed one below. Everything under this line that touches the
+ # ledger is the ORCHESTRATOR writing, not the session, so the flag is
+ # cleared here for each attempt to answer for itself; and NEVER on a
+ # replay, because the dead attempt's harvest is still in the tree
+ # while the replayed harvest dedupes to nothing, so its True has to
+ # survive (see the field's comment and `_harvest_gate_exclude`).
+ # Sharing the arm's guard would couple the two: the relaxation below
+ # fires on a replay that has nothing armed, which would clear the
+ # flag on exactly the replay whose only diff IS the dead attempt's
+ # harvest — handing the proof-of-work gate the orchestrator's own
+ # write, the hazard this flag exists to prevent (#405).
+ if not replayed:
+ task.harvest_wrote_ledger = False
+ if not replayed or not task.pre_harvest_ledger_captured:
+ task.pre_harvest_ledger = self._ledger_text()
+ task.pre_harvest_ledger_captured = True
+ # persists the clear above as well: `not replayed` implies this
+ # condition, so every pass that clears the flag saves here.
+ self._save()
# bmad-dev-auto sometimes finalizes the spec in prose (## Auto Run
# Result: Status done) but leaves the frontmatter status at the
# template default. Repair it BEFORE any frontmatter reader runs —
@@ -1756,6 +1997,16 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None
# skill never touches (sprint-status for stories, the deferred-work
# ledger for sweep bundles), before verify reads that state.
self._post_dev_state_sync(task, result.result_json)
+ # Harvest the session's frontmatter `deferred:` findings (#2640)
+ # into the ledger BEFORE _verify_dev_artifacts, so the edit is in
+ # the tree the story commit squashes — a ledger entry recording
+ # work deferred by an attempt that is later rolled back would be a
+ # claim about code that no longer exists. Placement gets the commit
+ # half only: the harvest fires on the spec's status, before the
+ # artifact gate that can still send this attempt back. The rollback
+ # half is enforced explicitly, by the snapshot armed above and the
+ # restore at the RETRY branch below.
+ self._harvest_spec_deferrals(task, result.result_json)
# carry the skill's follow-up-review recommendation (PR #2505)
# onto the task so _review_and_commit can gate the review loop.
# A present key is authoritative (folded from the frontmatter, or
@@ -1792,6 +2043,26 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None
)
self._save()
if decision.action == Action.PROCEED:
+ # The attempt is ACCEPTED: every gate that could still discard it has
+ # passed. Engine-side bookkeeping that makes a claim about this
+ # attempt's work belongs here rather than upstream of those gates.
+ self._post_dev_accepted_sync(task, result.result_json)
+ # Clear site 1. Nothing downstream can consume the snapshot — the
+ # phase returns and never re-enters this attempt — but without the
+ # clear a story that goes on to finish would carry a copy of the
+ # ledger in state.json for the rest of the run.
+ #
+ # The one clear site that DOES need its own `_save()`. A hard kill
+ # between here and the next ambient save resumes into
+ # `_finish_inflight`'s first branch (phase `DEV_VERIFY`, `spec_file`
+ # set by the artifact gate) → `_resume_after_dev_verify` →
+ # `_review_and_commit`, so `_dev_phase` is never re-entered and the
+ # armed snapshot is never consumed — it just rides the task into a
+ # terminal state.json. That is the opposite of clear site 2, where the
+ # replay re-enters this loop and still wants the snapshot; see
+ # `_disarm_ledger_snapshot`.
+ self._disarm_ledger_snapshot(task)
+ self._save()
self._emit("post_dev_phase", task)
if self._run_workflows("post_dev_phase", task, task.attempt):
return False
@@ -1803,8 +2074,77 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None
feedback = self._write_feedback(task, decision.reason)
else:
feedback = None
- self._rollback_or_pause(task)
+ paused = False
+ try:
+ self._rollback_or_pause(task)
+ except RunPaused:
+ # `rollback_on_failure = off` (the DEFAULT) does not roll
+ # back at all: it hands the tree to a human and raises. The
+ # gap that opens is not a crash window measured in
+ # milliseconds but a HUMAN-scale one — the operator reads the
+ # notice, inspects the tree, and may well append to the
+ # deferred-work ledger before typing `resume`. Latch it here
+ # so the `finally` below can tell that leg apart (#405).
+ paused = True
+ raise
+ finally:
+ # The reset discards the attempt but NOT what this phase
+ # wrote to the deferred-work ledger: the ledger lives under a
+ # protected artifact folder, so `_safe_reset`'s `keep`
+ # shields it from the untracked-file cleanup and a ledger
+ # this attempt CREATED outlives the work it describes (#405).
+ # Put it back by hand. `finally`, not a plain call after:
+ # rollback_on_failure OFF (the default) raises out of
+ # `_pause_for_manual_recovery`, and the manual `reset --hard`
+ # it prints would not remove an untracked ledger either.
+ # Lossless because the harvest never mutates the spec's
+ # `deferred:` frontmatter and the next attempt re-runs
+ # `_post_dev_state_sync` as well, so both reverted writes are
+ # simply re-made by whichever attempt survives.
+ #
+ # ONE snapshot, the same bytes either way: the live path and
+ # the crash-REPLAY path both restore the text persisted
+ # before the harvest, so a host death in that window costs
+ # nothing. No-op when this attempt armed nothing.
+ self._restore_persisted_ledger(task, replayed=replayed)
+ if paused:
+ # The restore above has just put the ledger back to this
+ # attempt's pre-harvest bytes, which is correct and is the
+ # last thing this snapshot is good for. Spend it HERE
+ # rather than letting it ride the pause: clear site 2
+ # below is unreachable on this leg (the raise skips it),
+ # so without this the copy stays armed in state.json
+ # across a pause of arbitrary length, and the resume —
+ # which replays this same attempt — writes those stale
+ # bytes back over whatever the operator did in the
+ # meantime. Every ledger state loses work that way, not
+ # just the created-by-the-harvest one: the overwrite arm
+ # has no `_ledger_is_gits_to_restore` guard, so a TRACKED
+ # ledger is rewritten too, and the arm is never spent, so
+ # it happens again on every subsequent resume.
+ #
+ # `_save()` immediately, because the whole point is that
+ # the value the RESUME reads off disk is the disarmed one.
+ # The re-arm then happens at the arm site from the tree as
+ # the operator left it — see its relaxed guard, which is
+ # what makes "nothing armed" mean "arm from disk" there.
+ self._disarm_ledger_snapshot(task)
+ self._save()
+ # Clear site 2, for BOTH retry legs and deliberately OUTSIDE the
+ # `finally`. A fixable retry keeps its tree and its harvest on
+ # purpose, so a stale arm would let attempt N+1's replay delete them;
+ # and when `_rollback_or_pause` RAISES (the `rollback_on_failure =
+ # off` default) control never reaches here — correctly, because the
+ # pause's own resume replays this same attempt and still needs the
+ # snapshot. Moving this into the `finally` breaks exactly that.
+ self._disarm_ledger_snapshot(task)
continue
+ # Clear site 3, covering DEFER and the PAUSE/escalate fall-through alike.
+ # Neither reverts: `_defer` snapshots the ledger around its own reset and
+ # writes the harvested entries back, and an escalation preserves the tree
+ # for the human. Both `_save()` immediately below, so the snapshot dies
+ # here rather than riding a paused or terminal task in state.json.
+ self._disarm_ledger_snapshot(task)
if decision.action == Action.DEFER:
self._record_dev_spec(task, result.result_json)
self._defer(task, decision.reason)
@@ -1950,6 +2290,13 @@ def _review_and_commit(
# frontmatter's followup flag into `rj` (only when present), so the
# convergence/damping gate below sees the finalized state.
self._reconcile_generic_terminal_status(task, rj)
+ # A review pass is a full dev-primitive run, so it triages and defers
+ # its own findings into the same frontmatter list. Harvest after the
+ # reconcile (which is what advances a prose-finalized spec to the
+ # success status this gates on) and before the convergence gate, so a
+ # converging pass's ledger edit still lands in the story commit. The
+ # dev leg's already-filed findings dedup on their fingerprint.
+ self._harvest_spec_deferrals(task, rj)
status = str(rj.get("status", "")).strip()
last_status = status # remember the last completed pass for the defer reason
followup = bool(rj.get("followup_review_recommended", False))
@@ -2187,6 +2534,30 @@ def _generic_dev(self) -> bool:
a future alternative dev skill can re-introduce the legacy branch."""
return self.policy.dev.skill == "bmad-dev-auto"
+ def _dev_skill(self, role: str = "dev") -> str:
+ """The dev-primitive skill NAME to spell in ``role``'s session prompt.
+
+ Upstream renamed the primitive ``bmad-dev-auto`` → ``bmad-build-auto``
+ (BMAD-METHOD #2651), so the invoked name is resolved from what is
+ actually on disk rather than hardcoded: a target project can be on
+ either era. This is NOT ``policy.dev.skill`` — that stays the adapter
+ discriminator ``_generic_dev`` reads; only the spelled name moves.
+
+ Resolution is per skill tree because one run can mix them (dev=claude →
+ ``.claude/skills``, review=codex → ``.agents/skills``), and memoized
+ because every prompt build would otherwise re-stat the tree. An adapter
+ with no ``profile`` (test fakes) yields tree None, which
+ ``dev_primitive_or_default`` maps to the legacy name.
+
+ Resolving against the main checkout is correct under worktree isolation
+ too: ``provision_worktree`` copies the skill from this same tree path,
+ so the worktree can only carry the name resolved here."""
+ adapter = self.adapters.get(role)
+ tree = getattr(getattr(adapter, "profile", None), "skill_tree", None)
+ if tree not in self._dev_skill_cache:
+ self._dev_skill_cache[tree] = dev_primitive_or_default(self.paths.project, tree)
+ return self._dev_skill_cache[tree]
+
def _dev_review_enabled(self) -> bool:
"""Spec-status/sprint semantics for verify_dev and the sprint sync. The
generic skill always self-finalizes to ``done`` (no in-review handoff), so
@@ -2369,9 +2740,14 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non
before ``verify_dev`` checks the sprint stage. Mirrors ``verify_dev``:
advance the story to the sprint stage matching the spec status the skill
actually reached, so a failed or blocked session (spec not at the success
- status) never advances the sprint. No-op for the legacy path; SweepEngine
- overrides this to flip the deferred-work ledger instead (bundles carry no
- sprint-status entry)."""
+ status) never advances the sprint. No-op for the legacy path.
+
+ Runs ABOVE the artifact gate because ``verify_dev`` reads what it writes —
+ the board must already say ``review``/``done`` or the story fails its own
+ gate. That is the whole justification for the position, and it does not
+ generalise: bookkeeping that no gate reads belongs in
+ ``_post_dev_accepted_sync`` instead. SweepEngine overrides this to a no-op
+ for exactly that reason (#405)."""
if not self._generic_dev():
return
spec_file = (result_json or {}).get("spec_file")
@@ -2390,6 +2766,189 @@ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> Non
target = "review" if review_enabled else "done"
sprint_advance(self.workspace.paths.sprint_status, task.story_key, target)
+ def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> None:
+ """Bookkeeping for a dev attempt the orchestrator has ACCEPTED — the seam for
+ engine-side writes that make a durable claim about the attempt's work.
+
+ The counterpart to ``_post_dev_state_sync``, and the default of the two. That
+ one runs above ``_verify_dev_artifacts`` only because ``verify_dev`` reads the
+ state it writes; anything that does NOT feed a gate belongs here, because
+ above the gate every terminus that discards the attempt has to be taught to
+ undo the write — and one of them, ``_defer``, does the exact opposite on
+ purpose (it snapshots the ledger around its own ``reset --hard`` and writes
+ those bytes back, so review-found deferrals survive). One accepted-only call
+ site collapses all of that into a single undo, `_reopen_ledger_after_defer`,
+ on the one leg that still needs one: a defer AFTER the attempt was accepted,
+ whether it comes from the review loop or from a blocking `post_dev_phase`
+ workflow.
+
+ Called after ``decide_dev`` returns PROCEED rather than on ``outcome.ok``, so
+ two more discards are excluded for free: a CRITICAL escalation in the result
+ json preempts even a passing outcome (``escalation.decide_dev``), and a
+ failing ``[verify] commands`` run replaces the outcome after the artifact
+ gate. No-op on the base path; ``SweepEngine`` closes a bundle's deferred-work
+ entries here."""
+ return
+
+ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> None:
+ """Carry the dev primitive's frontmatter `deferred:` findings into the
+ deferred-work ledger.
+
+ BMAD-METHOD #2640 moved the skill's `defer`-triaged review findings out of
+ `deferred-work.md` and into the spec's own frontmatter, on the reasoning
+ that the worker owns its spec and the orchestrator owns follow-up policy.
+ Without this harvest the sweep pipeline silently starves: the findings are
+ real, recorded, and invisible to every reader downstream.
+
+ Era-agnostic by construction. The gate is the FIELD's presence plus the
+ generic-dev seam — never the skill name resolved on disk — so a project on
+ either side of the rename behaves the same, and a pre-#2640 spec (no field)
+ simply harvests nothing while its flat ledger appends keep working.
+
+ Gated on the spec having reached the success status, mirroring the
+ append-on-success semantics the old step-04 ledger writer had: a blocked
+ spec keeps its findings in frontmatter and harvests them on the eventual
+ successful re-drive. That gate is only half of "a story that never lands
+ cannot seed the ledger with findings about work that was rolled back" —
+ it reads the SPEC's status, not whether the attempt's artifacts verified,
+ and a session can finalize its spec and still fail the artifact gate. The
+ rollback contract below carries the other half.
+
+ Idempotent across retries, crash-replay, and the dev→review double call:
+ each entry's `origin:` carries the finding's fingerprint, and the pre-scan
+ below matches it against ledger entries of EVERY status. `append_entry`'s
+ own dedup is open-only by design (a re-filed entry should reappear after a
+ human reopens the topic), which is exactly wrong here — a harvest replayed
+ after the entry was swept done would file it a second time.
+
+ The frontmatter is deliberately never mutated: block-scalar surgery on a
+ `deferred:` list is the frontmatter-edit trap in a nastier form, and the
+ fingerprinted `origin:` above already watermarks the ledger side.
+
+ Rollback is asymmetric between the two failure paths, and deliberately so.
+ A non-fixable RETRY reverts the ledger edit along with the work it
+ describes, and the next attempt re-harvests from the untouched
+ frontmatter. The reset does not achieve that by itself: the ledger sits
+ under a protected artifact folder, so `_safe_reset`'s `keep` shields it
+ from `safe_rollback`'s untracked cleanup and a ledger this harvest CREATED
+ would survive (#405). `_dev_phase` therefore snapshots the ledger ahead of
+ this call and restores it around `_rollback_or_pause`; that restore, not
+ the reset, is what makes the revert unconditional. The snapshot is persisted with the
+ attempt, so a REPLAYED one restores the same bytes rather than guessing;
+ see `_restore_persisted_ledger`.
+ A DEFER does NOT revert: `_defer` snapshots the ledger and writes it back
+ after the reset, keeping harvested entries. It has to —
+ `_stash_deferred_artifacts` moves the spec out of the artifacts dir first,
+ so after a defer the frontmatter is no longer where a re-drive would find
+ it, and the ledger entry is the finding's only surviving record.
+
+ Ledger writes are UNGUARDED — observation degrades, repair raises — so a
+ write fault reaches run()'s crash recorder instead of silently dropping
+ the findings.
+ """
+ if not self._generic_dev():
+ return
+ spec_file = (result_json or {}).get("spec_file")
+ if not spec_file:
+ return
+ spec_path = verify.resolve_spec_path(str(spec_file), self.workspace.paths)
+ if not spec_path.is_file():
+ return
+ fm = self._observed_frontmatter(spec_path, task.story_key, "spec-deferrals")
+ if fm is None:
+ return
+ success_status = "in-review" if self._dev_review_enabled() else "done"
+ if verify.status_of(fm) != success_status:
+ return
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ if not findings and not malformed:
+ return
+
+ spec_name = spec_path.name
+ # (origin, title, reason, location, severity) — one row per ledger entry
+ # this harvest may file.
+ pending: list[tuple[str, str, str, str | None, str | None]] = [
+ (
+ f"{HARVEST_ORIGIN} {f.fingerprint}",
+ f.summary,
+ f.evidence or f.summary,
+ f.location or None,
+ f.severity or None,
+ )
+ for f in findings
+ ]
+ if malformed:
+ self.journal.append(
+ "spec-deferrals-malformed",
+ story_key=task.story_key,
+ spec=spec_name,
+ items=malformed,
+ )
+ # One aggregated meta-entry, not one per bad item: the loss is a single
+ # fact about this spec, and filing it on the sweep channel is what stops
+ # a mangled `deferred:` block from being a silent drop. Fingerprinting
+ # the notes keeps a replay quiet while letting a spec whose malformed
+ # set actually changed file afresh.
+ pending.append(
+ (
+ f"{HARVEST_ORIGIN}-malformed "
+ f"{devcontract.harvest_fingerprint(spec_name, *malformed)}",
+ f"Unreadable `deferred:` items in {spec_name}",
+ "The dev session recorded deferred findings the orchestrator could not "
+ "parse, so they were NOT filed as entries: "
+ + "; ".join(malformed)
+ + f". Read `{spec_name}`'s frontmatter and re-file them by hand.",
+ None,
+ "low",
+ )
+ )
+
+ ledger = self.workspace.paths.deferred_work
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
+ seen = deferredwork.parse_ledger(text)
+ filed: list[str] = []
+ deduped = 0
+ for origin, title, reason, location, severity in pending:
+ if any(
+ deferredwork.field_line_present(e.body, "origin", origin)
+ and deferredwork.field_line_present(e.body, "source_spec", spec_name)
+ for e in seen
+ ):
+ deduped += 1
+ continue
+ dw_id = deferredwork.append_entry(
+ ledger,
+ title=title,
+ origin=origin,
+ source_spec=spec_name,
+ reason=reason,
+ location=location,
+ severity=severity,
+ )
+ # None means append_entry's own open-entry guard caught it — a
+ # duplicate the pre-scan snapshot could not see because THIS harvest
+ # just wrote it (two frontmatter items with identical summary and
+ # location).
+ if dw_id is None:
+ deduped += 1
+ else:
+ filed.append(dw_id)
+ # Set-only, never cleared here. A replay re-runs this harvest and dedupes
+ # every entry the dead attempt wrote, so `filed` comes back empty while
+ # those entries are still on disk — clearing on that would hand the gate
+ # below the orchestrator's own write. The per-attempt clear lives at the
+ # arm site.
+ if filed:
+ task.harvest_wrote_ledger = True
+ self.journal.append(
+ "spec-deferrals-harvested",
+ story_key=task.story_key,
+ spec=spec_name,
+ dw_ids=filed,
+ deduped=deduped,
+ malformed=len(malformed),
+ )
+
def _extra_session_env(
self, task: StoryTask, role: str, label: str | None = None
) -> dict[str, str]:
@@ -2423,9 +2982,41 @@ def _after_story(self, task: StoryTask) -> None:
unit is merged before the run stops."""
return
+ def _harvest_gate_exclude(self, task: StoryTask) -> tuple[str, ...]:
+ """The deferred-work ledger's project-relative path, when THIS attempt's
+ harvest filed into it — and `()` otherwise.
+
+ `_harvest_spec_deferrals` runs four statements above the artifact gate, and
+ `verify_dev_exclude_relpaths` deliberately does NOT exclude the ledger (a
+ story whose whole authorized scope is ledger reconciliation must register as
+ real work — `KNOWN-BUG-ledger-only-story-false-no-changes.md`). So without
+ this the gate cannot tell the orchestrator's write from the session's, and a
+ session that finalized its spec and changed no code PROCEEDs on the strength
+ of the harvest alone. That is the exact hazard `sweep.py`'s close was moved
+ below the gate to retire; moving the close audited only the write it moved.
+
+ Keyed on the persisted flag rather than re-derived, because a replay's
+ harvest dedupes to nothing while the dead attempt's entries remain in the
+ tree. Narrow by construction: it excludes the ledger only on the attempts
+ that actually wrote it, so a session's OWN ledger edit still counts as work
+ on every other attempt. The residual is a session that edits the ledger on
+ an attempt that also harvests — false-negatived, which is the safe
+ direction for a proof-of-work gate."""
+ if not task.harvest_wrote_ledger:
+ return ()
+ paths = self.workspace.paths
+ try:
+ return (paths.deferred_work.resolve().relative_to(paths.project.resolve()).as_posix(),)
+ except ValueError:
+ return () # ledger outside the project tree; nothing to exclude here
+
def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None):
return verify.verify_dev(
- task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled()
+ task,
+ self.workspace.paths,
+ result_json,
+ review_enabled=self._dev_review_enabled(),
+ engine_written=self._harvest_gate_exclude(task),
)
def _verify_review(self, task: StoryTask):
@@ -2438,14 +3029,20 @@ def _review_prompt(self, task: StoryTask) -> str:
# separate review skill. task.spec_file is set by verify_dev on success.
# The ledger instruction is the prevention side of the reclose in
# SweepEngine._verify_review: a review that rewrites deferred-work.md
- # from a stale snapshot clobbers orchestrator-recorded closures. The
- # ledger is append-only for sessions — new findings are fine, existing
- # entries are orchestrator-owned.
+ # from a stale snapshot clobbers orchestrator-recorded closures.
+ # Existing entries are orchestrator-owned; NEW ones are simply not asked
+ # for. Post-BMAD-METHOD#2640 the primitive records its own `defer`
+ # findings in the spec's frontmatter and `_harvest_spec_deferrals` files
+ # them, so an affirmative "append them" here files each finding twice:
+ # `append_entry`'s dedup is exact-match on `origin:` + `source_spec:` and
+ # an agent-written entry carries neither, so the pair can never collapse.
+ # Deliberately neutral rather than the sweep prompts' outright ban — on a
+ # pre-#2640 skill there is no frontmatter to harvest and the session's own
+ # flat append is the only record, so forbidding it would lose findings.
return (
- f"/bmad-dev-auto {task.spec_file} — If this review defers new "
- f"findings, append them to the deferred-work ledger as NEW entries "
- f"only; do NOT modify, re-open, or rewrite existing ledger entries — "
- f"the orchestrator owns their status and resolution."
+ f"/{self._dev_skill('review')} {task.spec_file} — do NOT modify, "
+ f"re-open, or rewrite existing deferred-work ledger entries; the "
+ f"orchestrator owns their status and resolution."
)
def _render_commit_template(self, task: StoryTask) -> str | None:
@@ -2577,8 +3174,17 @@ def _run_session(
# the same implementation-artifacts dir the dev adapter already
# searches — correct in place and under worktree isolation alike,
# because spec.cwd is self.workspace.root either way.
+ # ``role``, not the default: a workflow declares its own role
+ # (WORKFLOW_ROLES = dev | review) and runs on THAT adapter, whose
+ # skill tree can be a different one at a different era — dev=claude
+ # on .claude/skills post-rename, review=codex on .agents/skills
+ # pre-rename. Resolving off the dev tree would name the session a
+ # primitive its own tree does not carry. Discovery survives either
+ # spelling (FALLBACK_RESULT_PREFIXES matches both unconditionally),
+ # so this is the two halves agreeing, not a broken read-back.
marker_path = (
- self.workspace.paths.implementation_artifacts / f"bmad-dev-auto-result-{task_id}.md"
+ self.workspace.paths.implementation_artifacts
+ / f"{self._dev_skill(role)}-result-{task_id}.md"
)
prompt += WORKFLOW_COMPLETION_CONTRACT.format(marker_path=marker_path)
spec = SessionSpec(
@@ -2736,16 +3342,16 @@ def _generic_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
if feedback is None:
if task.restore_patch and task.spec_file:
return (
- f"/bmad-dev-auto Resume review of the in-review spec at "
+ f"/{self._dev_skill()} Resume review of the in-review spec at "
f"`{task.spec_file}`. The attempted change was restored onto "
f"the working tree after an intent-gap resolution; review it "
f"against the amended spec."
)
- return f"/bmad-dev-auto {task.story_key}"
+ return f"/{self._dev_skill()} {task.story_key}"
self._reset_spec_for_repair(task)
spec_ref = task.spec_file or task.story_key
return (
- f"/bmad-dev-auto Resume the autonomous dev session on the in-progress "
+ f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress "
f"spec at `{spec_ref}`. The previous session's work failed deterministic "
f"verification; repair the working tree so verification passes without "
f"changing the spec's frozen intent contract. Verification evidence is "
@@ -2980,6 +3586,10 @@ def _defer(self, task: StoryTask, reason: str) -> None:
if current != snapshot:
deferred_work.parent.mkdir(parents=True, exist_ok=True)
deferred_work.write_text(snapshot, encoding="utf-8")
+ # ...but that restore replays the whole file, so an entry the
+ # ORCHESTRATOR closed earlier in this story rides back over the reset
+ # too. Undo those specifically.
+ self._reopen_ledger_after_defer(task)
self.journal.append("story-deferred", story_key=task.story_key, reason=reason)
gates.notify(
self.policy,
@@ -2989,6 +3599,180 @@ def _defer(self, task: StoryTask, reason: str) -> None:
)
self._save()
+ def _reopen_ledger_after_defer(self, task: StoryTask) -> None:
+ """Undo the engine-side deferred-work CLOSES that `_defer`'s restore just
+ wrote back over its own `reset --hard`.
+
+ That restore is deliberate and stays: `_stash_deferred_artifacts` has already
+ moved the spec out of the artifacts dir, so a harvested finding's ledger entry
+ is its only surviving record. But it replays the whole file, so an entry the
+ orchestrator marked `status: done` earlier in this story is resurrected as
+ well — now naming code the reset discarded. The asymmetry is the whole reason
+ this exists: a surviving open finding reads as noise, while a surviving close
+ drops out of `deferredwork.open_ids`, which re-bundles only `open` entries, so
+ no later sweep looks at that id again and the work is silently lost.
+
+ `_post_dev_accepted_sync` closed the dev-leg legs of this by withholding the
+ close until the attempt is accepted. It cannot cover the REVIEW leg, because
+ by then the close describes an attempt that WAS accepted and has to be on disk
+ — `verify_review_bundle` requires it — and only the later review failure makes
+ it wrong (#405).
+
+ Runs on the branch where a reset was attempted: inside `baseline_commit`,
+ after the restore, and unreached when `_rollback_or_pause` raises —
+ the `rollback_on_failure = off` stop-and-wait path keeps the tree, so there is
+ nothing to undo. The isolated branch returns earlier still: its closes were
+ written in the unit's own worktree, which `_integrate_unit` drops unmerged.
+
+ No-op on the base path; only `SweepEngine` closes ledger entries."""
+ return
+
+ def _ledger_text(self) -> str | None:
+ """The deferred-work ledger's current text, or ``None`` when the file does
+ not exist. Workspace-scoped, so an isolated run reads the unit worktree's
+ own ledger — the same one `_rollback_or_pause` resets around."""
+ ledger = self.workspace.paths.deferred_work
+ return ledger.read_text(encoding="utf-8") if ledger.is_file() else None
+
+ def _ledger_is_gits_to_restore(self, task: StoryTask) -> bool:
+ """True when git owns the deferred-work ledger — it has an index entry, so
+ `_rollback_or_pause`'s `reset --hard` has already put it right and unlinking
+ it by hand would turn a reverted EDIT into a deleted FILE, which the next
+ attempt's `add -A` would then commit.
+
+ Asked with `verify.path_tracked` rather than `untracked_files`, because the
+ two are not complements: a GITIGNORED ledger is absent from the untracked set
+ as well, so reading "not untracked" as "tracked" silently files every ignored
+ ledger under "the reset already handled it" — and the reset never touches an
+ ignored path (#405).
+
+ Degrades to True. It runs inside a ``finally`` that is usually propagating
+ `RunPaused` out of `_pause_for_manual_recovery`, so a `GitError` raised here
+ would replace that pause with a git failure; and every caller uses the answer
+ to authorize a DELETE, so uncertainty has to mean "leave it alone"."""
+ ledger = self.workspace.paths.deferred_work
+ try:
+ rel = ledger.relative_to(self.workspace.root).as_posix()
+ except ValueError:
+ # configured outside the workspace: shared with other checkouts, and no
+ # rollback of ours is scoped to it. Not ours to delete either way.
+ return True
+ try:
+ return verify.path_tracked(self.workspace.root, rel)
+ except verify.GitError as e:
+ self.journal.append(
+ "ledger-tracked-probe-failed", story_key=task.story_key, error=str(e)
+ )
+ return True
+
+ def _restore_ledger(self, task: StoryTask, snapshot: str | None) -> None:
+ """Put the deferred-work ledger back to a pre-harvest ``_ledger_text()``
+ snapshot — `_defer`'s own snapshot/restore run in the opposite direction,
+ and for the opposite reason.
+
+ A ``None`` snapshot means the ledger did not exist yet, so the restore is
+ an unlink: the first harvest of a project CREATES `deferred-work.md`, which
+ is both the common case and the one the reset cannot undo (untracked, and
+ `keep`-shielded from `safe_rollback`'s cleanup). Skipping it would leave
+ the whole point of the revert unfixed.
+
+ ...unless git owns the path. "Absent when the snapshot was taken" is a
+ CREATION for an untracked or ignored ledger, but only a modification for a
+ tracked one the operator had deleted from the worktree without committing:
+ the harvest re-creates it, the reset restores the committed bytes, and an
+ unconditional unlink here would delete a tracked file that no rollback asked
+ to lose. Under `rollback_on_failure = off` no reset runs and the harvest's
+ file simply stays until the operator's own `reset --hard`, which lands in the
+ same place — so leaving it alone converges either way.
+
+ Empty parent dirs are deliberately not pruned: the artifacts dir is
+ orchestrator-owned, an empty one is harmless, and removing a directory the
+ session may still hold open is a worse trade than leaving it.
+
+ Writes raise, like every other ledger write — a failure here would silently
+ leave a stale finding pointing at code that was just discarded."""
+ ledger = self.workspace.paths.deferred_work
+ current = self._ledger_text()
+ if current == snapshot:
+ return
+ if snapshot is None:
+ if not self._ledger_is_gits_to_restore(task):
+ ledger.unlink(missing_ok=True)
+ return
+ ledger.parent.mkdir(parents=True, exist_ok=True)
+ ledger.write_text(snapshot, encoding="utf-8")
+
+ def _restore_persisted_ledger(self, task: StoryTask, *, replayed: bool) -> None:
+ """Put the deferred-work ledger back to the snapshot `_dev_phase` armed
+ before this attempt's engine-side ledger writes — if it armed one.
+
+ The snapshot is PERSISTED rather than held in a local, and that is the whole
+ point: a host death between `_harvest_spec_deferrals` and
+ `_rollback_or_pause` used to lose the local, leaving the dead attempt's
+ finding behind for the successful retry to commit.
+ `_resumable_session` replays the recorded result
+ and `_dev_phase` re-enters its loop from the top, so the replay reaches this
+ call with the dead attempt's own bytes still on the task.
+
+ ``pre_harvest_ledger_captured`` is the gate, NOT ``pre_harvest_ledger is not
+ None``. The text's ``None`` is a real value meaning "no ledger existed when
+ the snapshot was taken, so the restore is an unlink" (see `_restore_ledger`),
+ and that is the commonest case of all — the first harvest of a project
+ CREATES `deferred-work.md`. Collapsing the two states would turn exactly the
+ case the revert exists for into a no-op.
+
+ No-op when nothing was armed, silently on a live attempt and journalled on a
+ replayed one. Live: a non-``completed`` session never reaches the arm, and an
+ attempt with nothing to say about the ledger must not touch it — an
+ unconditional restore here would unlink an operator's pre-existing ledger.
+ Replayed: the arm is unreachable by construction (`_resumable_session` only
+ ever hands back a ``completed`` record), so an unarmed replay means the host
+ died upstream of the pre-harvest save, or the task predates these fields.
+ Hands off either way, but worth a journal line — that is a real, if narrow,
+ window in which a harvest survives its attempt.
+
+ Why the snapshot is a filesystem read and never a git query: the ledger is
+ commonly GITIGNORED (it sits under `output_folder`, which many projects
+ ignore — this one included), and every git-side signal is ignore-blind.
+ `baseline_untracked` and `verify.untracked_files` both come from
+ `git ls-files --others --exclude-standard`, so an ignored ledger is absent
+ from them whether the attempt created it or not; `reset --hard` never touches
+ an ignored path; this module never runs `git clean`; and `_safe_reset`'s
+ `keep` shields the artifacts dir besides. Only the bytes speak for tracked,
+ untracked and ignored ledgers alike."""
+ if not task.pre_harvest_ledger_captured:
+ if replayed:
+ self.journal.append("ledger-snapshot-missing", story_key=task.story_key)
+ return
+ self._restore_ledger(task, task.pre_harvest_ledger)
+
+ def _disarm_ledger_snapshot(self, task: StoryTask) -> None:
+ """Drop the armed pre-harvest ledger snapshot.
+
+ Armed from an attempt's pre-harvest `_save()` until its decision is acted on,
+ so the residency in state.json is bounded to an in-flight or paused attempt
+ rather than accumulating one ledger copy per finished story.
+
+ Deliberately NOT followed by a `_save()` at the RETRY site, and that
+ rationale is scoped to it (#405). Between that disarm and the next ambient
+ save the on-disk value is still armed, and a death in that window replays
+ *that same attempt* — which still wants the snapshot, so the stale-looking
+ persisted value is the correct one. Saving there would convert a correct
+ recovery into a hands-off one.
+
+ It does NOT generalise to the other three sites, and reading it as universal
+ left one real hole. Site 0 (fresh entry) is followed by a save on the veto
+ path deliberately, and a death before it leaves the phase `pending` ⇒ a
+ resume RESTARTS the story rather than replaying the attempt. Site 3
+ (DEFER / escalate) is saved immediately below by `_defer` / `_escalate`. Site
+ 1 (PROCEED) is the hole: a hard kill there resumes through
+ `_finish_inflight`'s `DEV_VERIFY` branch straight into `_review_and_commit`,
+ so `_dev_phase` is never re-entered, nothing ever consumes the snapshot, and
+ a terminal task carries a full copy of the ledger in state.json for good.
+ That site therefore saves explicitly."""
+ task.pre_harvest_ledger = None
+ task.pre_harvest_ledger_captured = False
+
def _stash_deferred_artifacts(self, task: StoryTask) -> None:
"""Move the deferred story's spec out of the artifacts dir into the run
dir: a leftover in-review spec would confuse the next attempt, but the
diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py
index 24375f18..46751991 100644
--- a/src/bmad_loop/install.py
+++ b/src/bmad_loop/install.py
@@ -16,12 +16,14 @@
from __future__ import annotations
+import errno
import json
+import re
import shutil
import subprocess
-from collections.abc import Iterable, Sequence
+from collections.abc import Iterable, Iterator, Sequence
from importlib import resources
-from pathlib import Path
+from pathlib import Path, PurePosixPath
from .adapters.profile import ALIASES, CLIProfile, ProfileError, load_profiles
from .checks import Finding
@@ -53,8 +55,9 @@
ANTIGRAVITY_HOOK_GROUP = "bmad-loop"
# The bmad-loop-* skills bundled in the wheel (bmad_loop/data/skills/) that
-# `bmad-loop init` lays down. The inner dev primitive `bmad-dev-auto` is upstream
-# (not bundled here): the orchestrator drives it as an already-installed skill.
+# `bmad-loop init` lays down. The inner dev primitive (`bmad-build-auto`, formerly
+# `bmad-dev-auto`) is upstream (not bundled here): the orchestrator drives it as an
+# already-installed skill.
MODULE_SKILLS = (
"bmad-loop-resolve",
"bmad-loop-sweep",
@@ -71,25 +74,166 @@
"bmad-auto-setup",
)
-# Upstream skills the orchestrator invokes but does NOT bundle in the wheel — the
-# BMad Method (bmm) module installs them. Each must exist in every active CLI skill
-# tree and carry its marker files (a half-installed or pre-automation skill is
-# caught by the `bmad-loop validate` preflight). `{skill: (marker-rel-path, ...)}`.
-# - bmad-dev-auto: the inner dev primitive — always required. Markers pin BOTH a
-# step file (catches a truncated copy) AND customize.toml, the layer/handoff
-# config step-04 resolves review_layers from (BMAD-METHOD #2535/#2550): a
-# pre-July bmm install predating it would let every dev run's step-04 fail.
-# - the three review hunters bmad-dev-auto's step-04 invokes inline on EVERY dev
-# run (and on each follow-up review re-invocation) — also always required, no
-# longer gated on a separate review session. bmad-review-verification-gap is
-# the newest layer (BMAD-METHOD #2550): a target project missing it makes the
-# verification-gap review layer fail on every run.
-DEV_BASE_SKILLS = {
- "bmad-dev-auto": ("step-04-review.md", "customize.toml"),
+# The inner dev primitive, in both upstream eras. BMAD-METHOD PR #2651 (shipped in
+# bmad-method 6.10.1-next.33) renamed `bmad-dev-auto` -> `bmad-build-auto` and left a
+# forwarding SHIM behind under the old name: a lone SKILL.md whose customization
+# migration gate is INTERACTIVE, so an unattended session that dispatches to it can
+# HALT having written nothing to disk — no spec, no result artifact, nothing the
+# post-session verification can read. The orchestrator therefore never accepts the
+# shim: it resolves the primitive on disk (resolve_dev_primitive) and fails the
+# preflight when only the shim is installed.
+#
+# The shim carries no step files and no customize.toml, so DEV_PRIMITIVE_MARKERS —
+# which already pinned "a real, complete install" — doubles as the shim detector.
+# Markers pin BOTH a step file (catches a truncated copy) AND customize.toml, the
+# layer/handoff config step-04 resolves review_layers from (BMAD-METHOD #2535/#2550):
+# a pre-July bmm install predating it would let every dev run's step-04 fail.
+#
+# REPO-side surface only: the shim detector, and what missing_base_skills demands of an
+# install. The worktree seed gate stopped sharing it — these two markers plus SKILL.md are
+# 3 of the 13 files a real bmad-build-auto carries, so it asks walk parity with the repo instead
+# (_absent_skill_files). A named tuple can be a floor for "is this install real"; it
+# cannot be the answer to "did the copy deliver everything".
+DEV_PRIMITIVE_NEW = "bmad-build-auto"
+DEV_PRIMITIVE_LEGACY = "bmad-dev-auto"
+DEV_PRIMITIVE_MARKERS = ("step-04-review.md", "customize.toml")
+
+# The adapter roles whose skill tree is asked for the dev primitive, the review
+# hunters that primitive invokes inline, and the renderer unit behind a stub SKILL.md
+# — i.e. every question this module's skill checks ask. Triage is deliberately absent:
+# its only prompt is `/bmad-loop-sweep`, which is in MODULE_SKILLS and is laid into
+# that tree by `bmad-loop init`, so a triage-only CLI never needs one byte of the bmm
+# module. Gating it anyway made `[adapter.triage] name = "gemini"` under a claude
+# dev/review pair demand the whole module in `.agents/skills` — a hard preflight FAIL
+# since the renderer checks became problems rather than warnings.
+#
+# This must stay the same set `Engine._worktree_profiles` provisions. A tree gated here
+# that no worktree carries refuses runs over a skill no session will ever read; a tree
+# provisioned but not gated ships a session into the `Unknown command` stall the
+# preflight exists to catch. Neither has a defensible reading, so the two move together
+# or not at all.
+DEV_PRIMITIVE_ROLES: tuple[str, ...] = ("dev", "review")
+
+# BMAD's config/tool dir at the project root. Everything the renderer reads hangs
+# off it, and the renderer takes the project root as an argument and hard-fails when
+# `/_bmad` is absent — there is no walk-up — so an isolated worktree
+# must carry its own (see _seed_bmad_tree).
+BMAD_DIR = "_bmad"
+
+# Since BMAD-METHOD PR #2601 a skill's SKILL.md can be a renderer *stub* that shells
+# out (via uv) to this project-local script to compose the real prompt. When the
+# script is absent the session HALTs without writing anything, so this is a preflight
+# FAIL: absence is SUFFICIENT for the HALT, which is the only question a gate asks.
+# That the probe is not sufficient for success (uv on PATH is never checked, nor is
+# the config's contents — see #407) argues against trusting a green, not against
+# blocking on a red.
+RENDERER_SCRIPT_REL = f"{BMAD_DIR}/scripts/render_skill.py"
+RENDERER_SCRIPT_MARKER = "render_skill.py"
+
+# render_skill.py's sibling helper, and the second half of the same unit: upstream
+# imports it at module scope off `sys.path[0]` with no try and no path fixup, so its
+# absence is a bare ModuleNotFoundError raised ABOVE the renderer's own
+# `except (ConfigError, RenderError, OSError, UnicodeError, ValueError)` guard.
+# Measured: the real script alone in an empty dir exits non-zero with a traceback and
+# NO `HALT: ` line. The stub's SKILL.md tells the agent to report the command
+# output and halt either way, so what is actually lost is the structured diagnosis —
+# the result-less Stop is identical, which is what makes this the same finding as an
+# absent script rather than a new one. Required only when the INSTALLED script
+# actually imports it (see _renderer_unit_required): both files are one commit old
+# upstream (c2530ea5, BMAD-METHOD #2601), and this gate has no --force.
+RENDERER_CONFIG_UTILS_REL = f"{BMAD_DIR}/scripts/config_utils.py"
+RENDERER_CONFIG_UTILS_MARKER = "config_utils"
+# The renderer's per-project script unit, in the order a finding should list it.
+RENDERER_SCRIPT_UNIT_REL: tuple[str, ...] = (RENDERER_SCRIPT_REL, RENDERER_CONFIG_UTILS_REL)
+
+# The bottom layer of the renderer's four-layer central config, and the only
+# REQUIRED one (`config_utils.load_central_config` passes the required=True flag to
+# its inner load_toml; the .user/custom layers above it are all optional). Absent,
+# the renderer raises before it composes anything, so the stub's `uv run` exits with
+# `HALT:` and the session Stops having written no spec — the same result-less failure
+# RENDERER_SCRIPT_REL guards, one file further in, and blocking for the same
+# reason. Project-global, not per tree.
+CENTRAL_CONFIG_REL = f"{BMAD_DIR}/config.toml"
+
+# The `provision_worktree` skipped-seed entry that reports the worktree's renderer
+# support came up SHORT, rather than a seed that turned out to be a no-op. Shared with
+# the engine because the entry is only half the story: whether a short seed matters at
+# all depends on the resolved dev primitive being a renderer STUB, and provisioning is
+# handed CLI profiles and a repo root — not the project root the primitive resolves
+# against — so the engine owns the escalate-or-not decision and reads this sentinel back
+# to make it. A magic string on either side would let the two drift silently apart, and
+# the failure mode of that drift is a run that dispatches into guaranteed result-less
+# Stops. See _bmad_scripts_seed_incomplete and Engine._run_isolated.
+BMAD_SCRIPTS_SEED_REL = f"{BMAD_DIR}/scripts"
+
+# Every sentinel above, i.e. the whole project-global surface the renderer needs to
+# have REACHED THE WORKTREE. The engine intersects this rather than testing one
+# constant, and names whichever member fired in the escalation reason — sending an
+# operator to `_bmad/scripts` when the config is what went missing is exactly the
+# drift the shared constants exist to prevent. The tuple is also the forgery filter
+# in provision_worktree: these strings are reserved for the completeness checks, so a
+# user `worktree_seed` entry that happens to spell one cannot pause a healthy run.
+RENDERER_SEED_SENTINELS: tuple[str, ...] = (BMAD_SCRIPTS_SEED_REL, CENTRAL_CONFIG_REL)
+
+# The SKILL-relative half of the renderer's surface — everything above is
+# project-global. Since BMAD-METHOD #2601 `render_skill.py` composes the real prompt
+# from `/workflow.md` and refuses to start without it (`render entry is
+# missing`), then rewrites every `[[bmad-snapshot:]]` token into a path under the
+# generation dir, raising `snapshot reference targets undeclared source` when the
+# target is not one of that skill's OWN sources. Both exit `HALT:` — the same
+# result-less Stop on every story that RENDERER_SCRIPT_REL and CENTRAL_CONFIG_REL
+# guard, one layer further in. See _absent_renderer_sources.
+RENDERER_ENTRY_REL = "workflow.md"
+# A byte-for-byte mirror of the renderer's own `_SNAPSHOT_TOKEN`. Mirroring is the
+# contract, not a convenience: a LOOSER pattern reports a token the renderer ignores
+# outright — a false positive this gate cannot be talked out of, having no severity
+# filter and no `--force` — and a STRICTER one misses a real HALT. Note the `.md`
+# suffix is part of the pattern upstream, so a token naming anything else is not a
+# snapshot reference at all and is left in the prompt as literal text.
+SNAPSHOT_TOKEN_RE = re.compile(r"\[\[bmad-snapshot:([A-Za-z0-9_./-]+\.md)\]\]")
+
+# Top-level _bmad/ entries never seeded into a worktree. render/ is the renderer's
+# published output: it is regenerated on skill entry, and every snapshot dir name is
+# keyed on a hash of the project root's absolute path, so seeding the main
+# checkout's copy would carry ITS paths into the worktree and make every parallel
+# session race on one shared tree.
+RENDER_DIR_NAME = "render"
+BMAD_SEED_EXCLUDES = (RENDER_DIR_NAME,)
+
+# The one spelling of the renderer's output dir, shared by the two shields that keep
+# it out of commits (`init`'s .gitignore line, the worktree exclude) and by
+# `validate`'s tracked-output probe. They have to agree: a probe that looked
+# somewhere the shields do not write would warn about a path nothing protects.
+RENDER_DIR_REL = f"{BMAD_DIR}/{RENDER_DIR_NAME}"
+
+# Upstream per-skill customization overrides live here, named after the skill. The
+# rename does NOT migrate them, so a project upgraded to bmad-build-auto silently
+# stops applying its bmad-dev-auto.toml — validate warns (v0.9.0's orchestrator has
+# no customize read site of its own, so this is purely an operator heads-up).
+CUSTOMIZE_DIR_REL = f"{BMAD_DIR}/custom"
+
+# The three review hunters the dev primitive's step-04 invokes inline on EVERY dev
+# run (and on each follow-up review re-invocation) — always required, no longer
+# gated on a separate review session. bmad-review-verification-gap is the newest
+# layer (BMAD-METHOD #2550): a target project missing it makes the verification-gap
+# review layer fail on every run. No markers: existence is the whole check.
+REVIEW_HUNTER_SKILLS: dict[str, tuple[str, ...]] = {
"bmad-review-adversarial-general": (),
"bmad-review-edge-case-hunter": (),
"bmad-review-verification-gap": (),
}
+
+# Upstream skills the orchestrator invokes but does NOT bundle in the wheel — the
+# BMad Method (bmm) module installs them. Each must exist in every `trees` entry —
+# callers pass the DEV_PRIMITIVE_ROLES trees, since only a dev or review session ever
+# dispatches one of these — and carry its marker files (a half-installed or
+# pre-automation skill is caught by the `bmad-loop validate` preflight). A tree only a
+# triage adapter reads is never asked: nothing here is in its dispatch path.
+# `{skill: (marker-rel-path, ...)}`.
+# The dev-primitive entry is keyed on the LEGACY name because this map is also the
+# "lay down a pre-rename install" catalog; missing_base_skills does not walk it for
+# the primitive — it resolves the installed name per tree first.
+DEV_BASE_SKILLS = {DEV_PRIMITIVE_LEGACY: DEV_PRIMITIVE_MARKERS, **REVIEW_HUNTER_SKILLS}
# Every non-bundled skill that might need copying into an isolated worktree.
# bmad-review is the merged lens-based reviewer (BMAD-METHOD core-streamline):
# on new bmm installs the three hunter IDs above are thin forwarders to it, so a
@@ -97,35 +241,252 @@
# DEV_BASE_SKILLS (preflight) so pre-merge bmm installs — which have the three
# real hunters and no bmad-review — keep validating; provision_worktree skips
# skills the main repo lacks, so copy-if-present is safe in both directions.
-BASE_SKILLS = {**DEV_BASE_SKILLS, "bmad-review": ()}
+# Both primitive eras are listed: a worktree must carry whichever one the main
+# checkout has, and copy-if-present makes naming both free. Adding the new name
+# here is what keeps isolation working across the rename.
+BASE_SKILLS = {DEV_PRIMITIVE_NEW: DEV_PRIMITIVE_MARKERS, **DEV_BASE_SKILLS, "bmad-review": ()}
-# Stories mode (folder+id dispatch, BMAD-METHOD #2549) needs a *newer* bmad-dev-auto
+# Stories mode (folder+id dispatch, BMAD-METHOD #2549) needs a *newer* dev primitive
# than sprint mode: one whose step-01 routes a spec-folder + story-id invocation.
# File existence (missing_base_skills) can't tell the two skill versions apart, so
# a content probe confirms the merged dispatch protocol is present. This literal is
# stable prose in the merged step-01 ("this is a **folder+id dispatch**").
-STORIES_PROBE_SKILL = "bmad-dev-auto"
+# STORIES_PROBE_SKILL names the FALLBACK era only — the probe runs against the skill
+# resolve_dev_primitive picked for that tree, so a bmad-build-auto install is probed
+# under its own name.
+STORIES_PROBE_SKILL = DEV_PRIMITIVE_LEGACY
STORIES_PROBE_FILE = "step-01-clarify-and-route.md"
STORIES_PROBE_TEXT = "folder+id dispatch"
+def resolve_dev_primitive(project: Path, tree: str) -> str | None:
+ """The dev-primitive skill name to drive in ``tree``, or None when none is usable.
+
+ Prefers :data:`DEV_PRIMITIVE_NEW`; falls back to :data:`DEV_PRIMITIVE_LEGACY`
+ only when that install is marker-complete, which is exactly what the post-rename
+ forwarding shim is not (see the constants block). None means "fail the preflight"
+ — never "drive the old name and hope".
+
+ The new name needs only its SKILL.md to *resolve*: completeness is reported by
+ :func:`missing_base_skills` against the resolved dir. Requiring markers here
+ instead would make a truncated bmad-build-auto silently resolve to a legacy
+ install (or to the shim's failure message), hiding the real problem.
+
+ Every probe is the TOTAL :func:`_is_file` rather than ``Path.is_file()``, because
+ this is not preflight-only: :func:`base_skills_seed_incomplete` resolves the era
+ through :func:`dev_primitive_or_default` on every provision, so a raise here is a
+ raise out of ``Engine._run_isolated`` — the one thing provisioning must never do
+ (see :func:`_merge_traversable`). Measured: with a bare ``is_file()`` an unreadable
+ ``bmad-build-auto`` dir in the repo took ``provision_worktree`` down with
+ ``PermissionError`` through 3.13 while 3.14 answered ``False``. Falling through to
+ the legacy name on a read fault is what 3.14 already did, and it is the right
+ answer: a complete legacy install is dispatchable and the run drives it; when
+ there is no usable legacy either, this returns None and the preflight refuses.
+ """
+ if _is_file(project / tree / DEV_PRIMITIVE_NEW / "SKILL.md"):
+ return DEV_PRIMITIVE_NEW
+ legacy = project / tree / DEV_PRIMITIVE_LEGACY
+ if _is_file(legacy / "SKILL.md") and all(
+ _is_file(legacy / marker) for marker in DEV_PRIMITIVE_MARKERS
+ ):
+ return DEV_PRIMITIVE_LEGACY
+ return None
+
+
+def _is_dev_primitive_shim(project: Path, tree: str) -> bool:
+ """True when ``tree`` holds a legacy-named skill that is only a forwarding shim
+ (SKILL.md present, at least one marker absent). Selects the failure *message*
+ in :func:`missing_base_skills`; it is never a resolution input."""
+ legacy = project / tree / DEV_PRIMITIVE_LEGACY
+ if not (legacy / "SKILL.md").is_file():
+ return False
+ return any(not (legacy / marker).is_file() for marker in DEV_PRIMITIVE_MARKERS)
+
+
+def _is_renderer_stub(skill_dir: Path) -> bool:
+ """True when ``skill_dir``'s SKILL.md is a renderer stub (BMAD-METHOD #2601) —
+ i.e. it shells out to ``render_skill.py`` rather than carrying the prompt inline.
+
+ Keyed on content, never on the installed skill name, so it answers the same for
+ both eras. An unreadable or binary SKILL.md cannot be *shown* to be a stub, so it
+ reads False: :func:`missing_base_skills`' marker checks have already spoken about
+ that tree's health, and inventing a renderer FAIL out of a read fault would blame
+ the wrong file."""
+ try:
+ skill_md = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ return False
+ return RENDERER_SCRIPT_MARKER in skill_md
+
+
+def _renderer_unit_required(project: Path, rel: str) -> bool:
+ """True when the renderer INSTALLED in ``project`` actually needs unit member ``rel``.
+
+ ``render_skill.py`` is the entry point and is unconditional. Its sibling is
+ content-keyed on the script that would import it, the same way
+ :func:`_is_renderer_stub` keys on SKILL.md: the import is a plain
+ ``from config_utils import ...`` at module scope, so the script's own text is the
+ only honest source of truth for whether the helper is load-bearing here.
+
+ Why key it at all, when upstream's script does import it: this feeds a preflight
+ with no severity filter and no ``--force`` — a false positive REFUSES every run
+ with a remediation that cannot fix it. Both files are one commit old upstream, so
+ if a later bmm inlines or renames the helper the import simply disappears and this
+ gate goes quiet instead. Unreadable or absent script ⇒ False, fail-open for the
+ same reason :func:`_is_renderer_stub` does: the script's own absence is already a
+ finding, and blaming a sibling for a read fault names the wrong file.
+ """
+ if rel != RENDERER_CONFIG_UTILS_REL:
+ return True
+ try:
+ script = (project / RENDERER_SCRIPT_REL).read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ return False
+ return RENDERER_CONFIG_UTILS_MARKER in script
+
+
+def _absent_renderer_sources(skill_dir: Path) -> list[str]:
+ """Render sources ``skill_dir`` needs and does not carry, as POSIX rels.
+
+ Two questions. They are not the whole set of ways ``render_skill.py`` can
+ ``HALT:`` — a ``{{config.…}}`` naming a missing key is another (#407) — but both
+ of these are answerable from the install alone, and each is a result-less Stop on
+ EVERY story: the same environment fault ``skills.dev-renderer`` and
+ ``skills.dev-renderer-config`` already block on, one layer further in:
+
+ - :data:`RENDERER_ENTRY_REL` is a source (``render entry is missing``);
+ - every ``[[bmad-snapshot:…]]`` target in any source is itself a source
+ (``snapshot reference targets undeclared source``).
+
+ The era conjunct is the first line of THIS function rather than a condition at
+ the call site: a pre-#2601 inline ``SKILL.md`` legitimately has no ``workflow.md``
+ and no step files, so asking it these questions refuses a healthy install. Kept
+ inside so it is one deletable line no neighbouring leg can short-circuit.
+
+ **The source set mirrors the RENDERER's enumeration, deliberately not the
+ copier's** :func:`_walk_traversable_files`. The two disagree exactly where it
+ matters: ``rglob`` does not descend a symlinked sub-directory and ``iterdir()``
+ recursion does, so a ``review-prompts/`` pointed out of the repo holds files the
+ copier would carry but the renderer will never see. This gate asks what
+ ``render_skill.py`` will SEE, so borrowing the copier's walk here would invent
+ sources the renderer does not have and turn a guaranteed HALT into a green.
+ (:func:`_absent_skill_files` asks the other question — did the seed deliver what
+ the repo has — and shares the copier's walk for the same reason.)
+
+ Keyed by POSIX rel because upstream keys by ``relative_to(skill_dir).as_posix()``:
+ a nested source is referenced as ``phases/plan.md``, never by its bare name, and a
+ native-separator key would report every nested target as undeclared on Windows.
+ ``SKILL.md`` is excluded at any depth, as upstream excludes it — a token naming it
+ HALTs there and must be reported here.
+
+ Deliberately not a fixed renderer-era file list — with ONE exception, named
+ because it is one. The gate has no severity filter and no ``--force``, so a false
+ positive refuses every run with a remediation that cannot fix it; asking only what
+ the install DECLARES cannot refuse an upstream that renames or reorganizes its
+ step files. :data:`RENDERER_ENTRY_REL` is the exception: upstream hardcodes that
+ name too (``if "workflow.md" not in sources``), so mirroring it is exactly as
+ stale-proof as upstream is — and no more.
+
+ Every source is scanned, not just the entry, because upstream substitutes tokens
+ across all of them. ``customize.toml`` is not, because upstream never loads it as
+ a source and its ``instruction`` prose would otherwise be mined for tokens the
+ renderer leaves as literal text — that is what the ``*.md`` filter is for. (Its
+ other half, that a non-``.md`` file could never be a token TARGET, is a tautology
+ of the token pattern rather than a guard.)
+
+ Fidelity is bounded, and this is the whole boundary. Upstream additionally
+ ``resolve(strict=True)``s each candidate and refuses one escaping the skill dir;
+ this does not, so a source symlinked out of the skill directory reads as present
+ here and ``HALT:``s there. That is a false GREEN — the safe direction for a gate
+ with no ``--force`` — and it is the only one left. The ``is_file()`` conjunct is
+ what keeps the rest out: without it a ``workflow.md`` that is a directory or a
+ dangling symlink reads as a source (both a ``HALT:`` upstream), and a FIFO
+ anywhere in the tree would block ``read_text`` forever — an unbounded hang in a
+ preflight, which is neither a green nor a refusal. An unreadable source is
+ likewise skipped rather than reported: also a HALT upstream, but a different one
+ with a different remediation, and fail-open on a read fault is the doctrine
+ :func:`_is_renderer_stub` and :func:`_renderer_unit_required` already follow.
+ """
+ if not _is_renderer_stub(skill_dir):
+ return []
+ sources = {
+ path.relative_to(skill_dir).as_posix(): path
+ for path in skill_dir.rglob("*.md")
+ if path.name != "SKILL.md" and path.is_file()
+ }
+ absent = [] if RENDERER_ENTRY_REL in sources else [RENDERER_ENTRY_REL]
+ for path in sources.values():
+ try:
+ body = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ continue
+ absent.extend(t for t in SNAPSHOT_TOKEN_RE.findall(body) if t not in sources)
+ return sorted(set(absent))
+
+
+def renderer_stub_resolved(project: Path, trees: Sequence[str]) -> bool:
+ """True when the dev primitive ANY active tree resolves to is a renderer stub
+ (BMAD-METHOD #2601) — i.e. some session this run spawns will compose its prompt by
+ shelling out to ``_bmad/scripts/render_skill.py`` instead of reading it inline.
+
+ The public form of the ``resolved_stub`` flag :func:`missing_base_skills` folds
+ into ``skills.dev-renderer``/``skills.dev-renderer-config``, lifted out for the
+ caller that needs the answer without the findings: ``Engine._run_isolated``, which
+ asks of a WORKTREE's seeded ``_bmad/`` the question the preflight already asked of
+ the main checkout. Sharing the predicate rather than restating the marker test is
+ what keeps the run-time gate era-agnostic in the same way the preflight is — on a
+ pre-#2601 inline SKILL.md nothing ever reads ``_bmad/scripts/``, so a missing
+ renderer is not a finding to make about that project, at either layer.
+
+ ANY over the trees, deduped exactly like :func:`missing_base_skills`: resolution is
+ per tree (one run can mix ``.claude/skills`` and ``.agents/skills``, and an operator
+ can upgrade the bmm module in one and not the other), and a single stubbed tree is
+ enough for one of the run's sessions to need the renderer.
+
+ False when nothing resolves. An unresolvable tree is :func:`missing_base_skills`'
+ story (``skills.base-missing``/``skills.base-shim``) and has already refused the run
+ at preflight, so no caller here invents a second answer for it; an empty ``trees``
+ (an adapter with no profile) has no tree to shell out from at all."""
+ for tree in dict.fromkeys(trees):
+ resolved = resolve_dev_primitive(project, tree)
+ if resolved is not None and _is_renderer_stub(project / tree / resolved):
+ return True
+ return False
+
+
+def dev_primitive_or_default(project: Path, tree: str | None) -> str:
+ """Total form of :func:`resolve_dev_primitive` for prompt builders.
+
+ A prompt string always has to name *something*, and the preflight has already
+ refused the unresolvable cases before any session is spawned — so an
+ unresolvable tree (and a None tree, which is what an adapter with no profile
+ reports) falls back to the legacy name rather than raising into prompt
+ construction."""
+ if tree is None:
+ return DEV_PRIMITIVE_LEGACY
+ return resolve_dev_primitive(project, tree) or DEV_PRIMITIVE_LEGACY
+
+
def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding]:
- """Problems for stories mode's stricter bmad-dev-auto requirement.
+ """Problems for stories mode's stricter dev-primitive requirement.
- Sprint mode drives any bmad-dev-auto; stories mode needs the folder+id
- dispatch flow, which older skill versions lack. For each active CLI skill
- tree, confirm ``bmad-dev-auto/step-01-clarify-and-route.md`` exists and
- carries the dispatch-protocol marker. Returns one problem :class:`Finding`
- per tree lacking it (empty = OK). Callers gate this on stories mode only —
- sprint-mode runs must not require the newer skill.
+ Sprint mode drives any dev primitive; stories mode needs the folder+id
+ dispatch flow, which older skill versions lack. For every ``trees`` entry —
+ callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
+ session ever dispatches one of these — confirm
+ ``/step-01-clarify-and-route.md`` exists and carries the
+ dispatch-protocol marker. Returns one problem :class:`Finding` per tree lacking
+ it (empty = OK). Callers gate this on stories mode only — sprint-mode runs must
+ not require the newer skill.
The two failures are separate check ids because they are separate conditions
with separate remediations: ``-missing`` is a half install (reinstall the
module), ``-stale`` is an install that is simply too old (update it)."""
problems: list[Finding] = []
for tree in dict.fromkeys(trees):
- probe = project / tree / STORIES_PROBE_SKILL / STORIES_PROBE_FILE
- detail = {"tree": tree, "skill": STORIES_PROBE_SKILL, "file": STORIES_PROBE_FILE}
+ skill = dev_primitive_or_default(project, tree)
+ probe = project / tree / skill / STORIES_PROBE_FILE
+ detail = {"tree": tree, "skill": skill, "file": STORIES_PROBE_FILE}
try:
text = probe.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
@@ -137,7 +498,7 @@ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding
Finding(
"skills.stories-dispatch-missing",
"problem",
- f"{tree}/{STORIES_PROBE_SKILL}/{STORIES_PROBE_FILE} not found — stories "
+ f"{tree}/{skill}/{STORIES_PROBE_FILE} not found — stories "
f"mode needs folder+id dispatch; update the BMad Method (bmm) module",
detail,
)
@@ -148,9 +509,9 @@ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding
Finding(
"skills.stories-dispatch-stale",
"problem",
- f"{tree}/{STORIES_PROBE_SKILL} lacks folder+id dispatch (no "
+ f"{tree}/{skill} lacks folder+id dispatch (no "
f"{STORIES_PROBE_TEXT!r} in {STORIES_PROBE_FILE}) — stories mode needs a "
- f"newer bmad-dev-auto; update the bmm module",
+ f"newer {skill}; update the bmm module",
{**detail, "marker": STORIES_PROBE_TEXT},
)
)
@@ -160,22 +521,156 @@ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding
def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]:
"""Problems for the upstream skills the orchestrator drives but doesn't bundle.
- The dev primitive (bmad-dev-auto) and the three review hunters it invokes
- inline — adversarial-general, edge-case-hunter, and verification-gap — are
- installed by the BMad Method module, not by `bmad-loop init`. Each must exist
- in every active CLI skill tree and carry its marker files. Returns one problem
- :class:`Finding` per missing/incomplete skill; empty list means OK. Run as a
- preflight so a missing skill fails loudly with remediation instead of stalling
- as an `Unknown command` until the run times out.
+ The dev primitive (bmad-build-auto, or a complete pre-rename bmad-dev-auto) and
+ the three review hunters it invokes inline — adversarial-general,
+ edge-case-hunter, and verification-gap — are installed by the BMad Method
+ module, not by `bmad-loop init`. Each must exist in every ``trees`` entry —
+ callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
+ session ever dispatches one of these — and carry its marker files. Returns one
+ problem :class:`Finding` per missing/incomplete skill; empty list means OK. Run
+ as a preflight so a missing skill fails loudly with remediation instead of
+ stalling as an `Unknown command` until the run times out.
+
+ The primitive is resolved per tree (:func:`resolve_dev_primitive`) before any
+ marker check, so the markers are asserted against the skill this run would
+ actually drive. That splits the failures three ways:
+
+ - ``skills.base-incomplete`` — one resolved, but it is truncated.
+ - ``skills.base-shim`` — nothing resolved, yet a legacy-named SKILL.md is there.
+ - ``skills.base-missing`` — nothing at all under either name.
+
+ A truncated *legacy* install is byte-for-byte the same shape as the shim (old
+ SKILL.md, absent markers), so it lands on ``base-shim`` rather than
+ ``base-incomplete``; nothing on disk can tell those two apart, so the message
+ names both causes and the single remediation they share. What the ids DO
+ separate is what a consumer can act on differently: resolved-but-truncated
+ (reinstall that skill) vs nothing-usable-resolved (update the module).
``skills.base-incomplete`` carries ``missing_markers`` as a list — the message
joins it with ", " for the human line, which a consumer would otherwise have to
split back apart on a separator the message is free to change.
+
+ A resolved *renderer stub* (BMAD-METHOD #2601) is probed two files further in,
+ because a stub whose script or required config layer is absent is the same
+ result-less HALT as the shim above and must fail the same gate:
+
+ - ``skills.dev-renderer`` — the stub is there, at least one member of the
+ renderer's script unit (:data:`RENDERER_SCRIPT_UNIT_REL`) is not. Per tree,
+ since the primitive resolves per tree. ONE id for both members: a missing entry
+ point and a missing sibling are the same result-less Stop with the same
+ remediation, and ``checks.py`` splits ids only where the two outcomes are
+ genuinely different findings. Which ones are absent is the ``missing_scripts``
+ detail — project-relative rels, hence not the skill-relative ``missing_markers``
+ key above.
+ - ``skills.dev-renderer-config`` — a stub resolved *somewhere* and the project has
+ no ``_bmad/config.toml``. Once per project (the central config is
+ project-global), and gated on a stub having resolved so the check stays
+ era-agnostic: a pre-#2601 inline SKILL.md never reads the file, so its absence
+ is not a finding to make about that project.
+ - ``skills.dev-renderer-sources`` — a stub resolved and the SKILL's own render
+ sources are short: no ``workflow.md`` entry, or a ``[[bmad-snapshot:…]]`` token
+ naming something the renderer will not load as a source — usually an absent
+ file, but ``SKILL.md`` and a directory named ``*.md`` count too
+ (:func:`_absent_renderer_sources`). Per tree, and era-gated inside the helper.
+ Without it the marker pair answered for thirteen files: a truncated repo-side
+ install passed ``validate`` and then HALTed every session. ``missing_sources``
+ carries the rels, SKILL-relative like ``missing_markers`` — not
+ ``missing_scripts``, which is project-relative because its files are.
+
+ All three are emitted independently of the marker check above them — different
+ files, different remediations, and a wholly-absent ``_bmad/`` legitimately earns
+ both project-global lines beside a truncated skill.
"""
- required = dict(DEV_BASE_SKILLS)
problems: list[Finding] = []
+ # Same predicate as :func:`renderer_stub_resolved`, kept inline here rather than
+ # delegated: this loop needs the per-tree `resolved`/`skill_dir` to attribute the
+ # `skills.dev-renderer` finding to a tree, which a bare any() discards.
+ resolved_stub = False
for tree in dict.fromkeys(trees):
- for skill, markers in required.items():
+ resolved = resolve_dev_primitive(project, tree)
+ if resolved is None and _is_dev_primitive_shim(project, tree):
+ legacy_dir = project / tree / DEV_PRIMITIVE_LEGACY
+ absent = [m for m in DEV_PRIMITIVE_MARKERS if not (legacy_dir / m).is_file()]
+ problems.append(
+ Finding(
+ "skills.base-shim",
+ "problem",
+ f"{tree}/{DEV_PRIMITIVE_LEGACY} is unusable (missing "
+ f"{', '.join(absent)}) and {DEV_PRIMITIVE_NEW} is not installed — "
+ f"most likely the forwarding shim the BMad Method's rename left "
+ f"behind, otherwise a truncated install; update the bmm module. The "
+ f"shim's migration prompt is interactive and would HALT an unattended "
+ f"session without writing anything to disk",
+ {
+ "tree": tree,
+ "skill": DEV_PRIMITIVE_LEGACY,
+ "expected": DEV_PRIMITIVE_NEW,
+ "missing_markers": absent,
+ },
+ )
+ )
+ elif resolved is None:
+ problems.append(
+ Finding(
+ "skills.base-missing",
+ "problem",
+ f"{tree}/{DEV_PRIMITIVE_NEW} not found — install the BMad Method (bmm) "
+ f"module (the orchestrator drives this upstream skill directly; older "
+ f"installs name it {DEV_PRIMITIVE_LEGACY})",
+ {"tree": tree, "skill": DEV_PRIMITIVE_NEW},
+ )
+ )
+ else:
+ skill_dir = project / tree / resolved
+ absent = [m for m in DEV_PRIMITIVE_MARKERS if not (skill_dir / m).is_file()]
+ if absent:
+ problems.append(
+ Finding(
+ "skills.base-incomplete",
+ "problem",
+ f"{tree}/{resolved} is incomplete (missing {', '.join(absent)}) — "
+ f"reinstall it from the bmm module",
+ {"tree": tree, "skill": resolved, "missing_markers": absent},
+ )
+ )
+ if _is_renderer_stub(skill_dir):
+ resolved_stub = True
+ absent_scripts = [
+ rel
+ for rel in RENDERER_SCRIPT_UNIT_REL
+ if _renderer_unit_required(project, rel) and not (project / rel).is_file()
+ ]
+ if absent_scripts:
+ problems.append(
+ Finding(
+ "skills.dev-renderer",
+ "problem",
+ f"{tree}/{resolved}/SKILL.md renders via {RENDERER_SCRIPT_MARKER} "
+ f"but the renderer script unit is incomplete (missing "
+ f"{', '.join(absent_scripts)}) — the session would HALT without "
+ f"writing a spec; reinstall the BMad Method (bmm) module",
+ {"tree": tree, "skill": resolved, "missing_scripts": absent_scripts},
+ )
+ )
+ # Unconditional: the era question lives inside the helper, so this leg
+ # cannot be short-circuited by the one above it and a future edit to the
+ # era test has one place to go rather than a condition here to keep in
+ # step. (The `if _is_renderer_stub` above is that test's other reader —
+ # they are two call sites of one predicate, not two predicates.)
+ absent_sources = _absent_renderer_sources(skill_dir)
+ if absent_sources:
+ problems.append(
+ Finding(
+ "skills.dev-renderer-sources",
+ "problem",
+ f"{tree}/{resolved} renders via {RENDERER_SCRIPT_MARKER} but its "
+ f"render sources are incomplete (unresolved: "
+ f"{', '.join(absent_sources)}) — the session would HALT without "
+ f"writing a spec; reinstall the BMad Method (bmm) module",
+ {"tree": tree, "skill": resolved, "missing_sources": absent_sources},
+ )
+ )
+ for skill, markers in REVIEW_HUNTER_SKILLS.items():
skill_dir = project / tree / skill
if not (skill_dir / "SKILL.md").is_file():
problems.append(
@@ -199,9 +694,64 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]:
{"tree": tree, "skill": skill, "missing_markers": absent},
)
)
+ if resolved_stub and not (project / CENTRAL_CONFIG_REL).is_file():
+ problems.append(
+ Finding(
+ "skills.dev-renderer-config",
+ "problem",
+ f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but "
+ f"{CENTRAL_CONFIG_REL} is missing — the renderer requires that layer and "
+ f"would HALT without writing a spec; reinstall the BMad Method (bmm) module",
+ {"config": CENTRAL_CONFIG_REL},
+ )
+ )
return problems
+def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding]:
+ """Advisory findings about a resolved dev primitive — validate-only, never a gate.
+
+ One condition, and it is genuinely survivable — which is what keeps it out of
+ :func:`missing_base_skills`:
+
+ - ``skills.customize-legacy``: the tree resolved to the NEW name while a
+ customization override still sits under the OLD one with no counterpart, i.e.
+ the rename silently orphaned it. Emitted once per project (the override files
+ are project-global, not per tree). The session still runs; it just runs
+ unstyled, so naming it is an operator heads-up rather than a gate.
+
+ The two renderer conditions this used to carry (``skills.dev-renderer``,
+ ``skills.dev-renderer-config``) moved into :func:`missing_base_skills` as
+ problems: each is a deterministic HALT-without-writing-a-spec, the same failure
+ the shim check already blocks on, so previewing a run that cannot possibly
+ produce one was the wrong service to offer.
+
+ Returns [] when nothing resolves — :func:`missing_base_skills` owns that story.
+ """
+ findings: list[Finding] = []
+ resolved_new = any(
+ resolve_dev_primitive(project, tree) == DEV_PRIMITIVE_NEW for tree in dict.fromkeys(trees)
+ )
+ if resolved_new:
+ orphaned = [
+ f"{CUSTOMIZE_DIR_REL}/{DEV_PRIMITIVE_LEGACY}{suffix}"
+ for suffix in (".toml", ".user.toml")
+ if (project / CUSTOMIZE_DIR_REL / f"{DEV_PRIMITIVE_LEGACY}{suffix}").is_file()
+ and not (project / CUSTOMIZE_DIR_REL / f"{DEV_PRIMITIVE_NEW}{suffix}").is_file()
+ ]
+ if orphaned:
+ findings.append(
+ Finding(
+ "skills.customize-legacy",
+ "warning",
+ f"{', '.join(orphaned)} no longer applies — the dev primitive is now "
+ f"{DEV_PRIMITIVE_NEW}; rename the override file(s) to match",
+ {"files": orphaned, "skill": DEV_PRIMITIVE_NEW},
+ )
+ )
+ return findings
+
+
def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> str:
host = get_process_host()
interp = host.hook_interpreter()
@@ -403,11 +953,348 @@ def _copy_traversable(src, dst: Path) -> None:
dst.write_bytes(src.read_bytes())
+def _walk_traversable_files(
+ src, rel: str = "", _seen: frozenset[str] = frozenset()
+) -> Iterator[tuple[str, object]]:
+ """Yield ``(rel, traversable)`` for the leaves of ``src``: every entry this walk
+ neither descends into nor has already covered under another rel.
+
+ The one walk both halves of the skills seed share: :func:`_merge_traversable`
+ copies what this yields and :func:`_absent_skill_files` asks the worktree for the
+ same rels. Sharing is the whole point, not tidiness. ``Path.rglob``/``**`` does
+ **not** descend a symlinked sub-directory while ``iterdir()`` + ``is_dir()``
+ does, so a parity check that merely *mirrored* the copier with an rglob would
+ walk straight past a ``review-prompts/`` symlinked to a shared install — the
+ exact drop the gate exists to report. Any future edit to how the copier
+ recurses is therefore an edit to what the gate asks for, by construction.
+
+ Traversal only: every containment decision stays with the caller, so this needs
+ no ``repo_root`` and answers the same for wheel package data as for a filesystem
+ tree (``.iterdir()``/``.is_dir()`` are the Traversable API, so a zip-imported
+ package walks too). Sorted per level, so a copy order and a report order are
+ both deterministic.
+
+ ``rel`` is built by concatenation and is POSIX on every platform — never
+ ``relative_to``, whose separator is the host's, which would make the reported
+ rels (and the exclude patterns keyed off them) diverge on Windows. Map back with
+ ``joinpath(*rel.split("/"))``; a file source yields ``""``, and
+ ``joinpath("")`` is a no-op, so it still names ``dst`` itself.
+
+ An entry that is neither file nor directory — a dangling symlink, a FIFO — is
+ yielded like any other leaf, so the caller's guards see exactly what the old
+ recursion handed them. By the same rule an ABSENT ``src`` yields one ``("", src)``
+ pair rather than nothing: "not a directory" is the only question asked, and a file
+ source has to yield itself for :func:`_merge_traversable` to copy it. That pair
+ used to need a guard in front of every caller to keep it from becoming a bogus
+ ``""`` rel; it no longer does, because the consumers grew their own
+ ``_is_file``/``_is_dir`` filters and an absent path answers False to both. Measured
+ (A18): deleting the ``BASE_SKILLS`` loop's ``src_root`` dir-guard now reddens
+ NOTHING — it bounds the walk and nothing more, which is the whole reason it is
+ still safe to keep. ⚠️ Do not read the same of
+ :func:`base_skills_seed_incomplete`'s ``SKILL.md`` precondition, which this walk is
+ now called on BOTH sides of: deleting it reddens 16 tests that predate this phase,
+ because it is the absent-from-the-repo branch itself and not a guard on the walk.
+
+ An entry the walk cannot see INSIDE is yielded as a LEAF rather than raising, and
+ that single choice is what makes an unreadable subtree reportable instead of fatal.
+ Two faults reach it, and :func:`_is_dir` is what folds them into one case: a
+ directory that cannot be listed (``iterdir`` raises), and an entry that cannot be
+ CLASSIFIED at all, which is what a directory readable-but-not-searchable (mode
+ ``0o444``) makes of every child — ``iterdir`` succeeds and then each child's own
+ ``stat`` is refused. The consumers then split on one predicate: the copier takes
+ ``_is_file`` alone (nothing it cannot confirm is deliverable content), the gates
+ take ``_is_file or _is_dir``, and the only yielded entry that answers the second
+ alone is one of those two — a descendable directory is recursed into, and a cycle
+ repeat is dropped with no rel at all, its content having already been yielded under
+ the ancestor that closed the loop. So the seed skips it and the gate names it.
+ ⚠️ Raising instead takes an unreadable repo skill out through
+ :meth:`Engine._run_isolated`, which has no ``try`` around provisioning, killing the
+ whole RUN rather than pausing on one story's named escalation (measured:
+ ``crash.txt`` + ``state.crashed``).
+
+ ``_seen`` carries the resolved real path of every directory on the CURRENT branch
+ so a symlink CYCLE terminates. ``rglob`` needs no such guard because it does not
+ descend child symlinks; descending them is this walk's whole reason to exist, and
+ it makes ``_bmad/scripts/loop -> ..`` unbounded. Measured before the guard, on the
+ fixture the cycle test still carries: 42 files, every one passing containment
+ because ``resolve()`` collapses the loop back inside the repo, terminating only by
+ accident when the kernel's ``ELOOP`` made ``is_dir()`` answer False — at a depth
+ that moves with the absolute path, which is what the limit actually bounds. Branch-local, not global: two sibling symlinks pointing at one shared
+ install are not a cycle, and one set shared across the whole walk would silently
+ drop the second one's rels from both the copy and the gate. Real paths only for
+ ``Path`` sources — a zip-imported Traversable has no ``resolve()`` and no way to
+ make a cycle.
+
+ Lazy across levels and it must stay that way, so a caller that stops early does not
+ pay for the rest of the tree. (Within one level ``sorted()`` has always been eager;
+ ``Path.iterdir()`` is a generator over ``os.listdir`` through 3.12, deferring the
+ read to the first ``next()``, and performs an eager ``os.scandir`` from 3.13 —
+ wrapping it in ``sorted()`` makes that difference invisible.)
+ """
+ if _is_dir(src):
+ real = str(src.resolve()) if isinstance(src, Path) else None
+ if real is not None and real in _seen:
+ return
+ try:
+ children = sorted(src.iterdir(), key=lambda entry: entry.name)
+ except OSError:
+ yield rel, src
+ return
+ inner = _seen if real is None else _seen | {real}
+ for child in children:
+ yield from _walk_traversable_files(
+ child, f"{rel}/{child.name}" if rel else child.name, inner
+ )
+ return
+ yield rel, src
+
+
+def _is_file(path) -> bool:
+ """``Path.is_file()`` made TOTAL: False rather than an exception on an IO fault.
+
+ ``Path.is_file()`` is not total, and not even consistently non-total across the
+ interpreters this project supports. Measured on 3.11/3.12/3.13/3.14: probing a file
+ whose PARENT directory is not searchable **raises** ``PermissionError`` through 3.13
+ — ``pathlib``'s ``_IGNORED_ERRNOS`` is ``(ENOENT, ENOTDIR, EBADF, ELOOP)`` and
+ ``EACCES`` is not in it — while 3.14 routes through ``os.path.isfile`` and answers
+ ``False``. Both readings are wrong here in different ways, and a check that crashes
+ on three of the four supported interpreters and goes quiet on the fourth is worse
+ than either.
+
+ Answers the caller's question, not the filesystem's: **nothing we cannot confirm is
+ a usable file**. That is true whether the file is absent, is a directory, or sits
+ behind a door we cannot open, and all three are the same fact for the session that
+ has to read it — and for the copier, which has no bytes it can promise to deliver.
+ Its sibling :func:`_is_dir` folds the same fault the other way for the same reason;
+ together they are the rule that when a probe cannot answer, the seed stays cautious
+ and the gate stays loud.
+
+ Where the REPO side is probed this way the reading is deliberate rather than
+ incidental, because there False can mean "the project legitimately lacks this" and
+ must not swallow an unreadable install: :func:`resolve_dev_primitive` picks a name
+ and a raise from it lands in a provision; :func:`_bmad_scripts_seed_incomplete` and
+ :func:`_central_config_seed_incomplete` go quiet on a ``_bmad/`` that cannot be read
+ only because the run-start preflight has already refused it (see
+ :func:`_seed_bmad_tree`); and :func:`base_skills_seed_incomplete` splits the two
+ apart explicitly rather than letting False stand for both.
+
+ ⚠️ Only the ≤3.13 lanes exercise the ``except``. On 3.14 the wrapped call is already
+ total, so ablating this wrapper reddens nothing there — measured, 6 reds on 3.13 and
+ 0 on 3.14, so the CI matrix and not the local suite is what covers both readings.
+ That is safe HERE only because both readings answer False; :func:`_is_dir` needed
+ more than a wrapper precisely because its two do not agree. Unannotated for the same
+ reason
+ :func:`_copy_traversable` is: the walk yields wheel Traversables as well as Paths.
+ """
+ try:
+ return path.is_file()
+ except OSError:
+ return False
+
+
+def _is_dir(path) -> bool:
+ """``Path.is_dir()`` made TOTAL — and True, not False, when it cannot answer.
+
+ The opposite fallback to :func:`_is_file`, because it answers the opposite
+ question: not "can this be delivered" but **"is there something here the walk has
+ not seen inside"**. An entry whose ``stat`` is refused might hold a whole subtree;
+ calling it False would make it indistinguishable from an absent path, and that
+ collapse is the exact silent failure the completeness gate exists to prevent (see
+ :func:`base_skills_seed_incomplete`). Calling it True costs one ``iterdir`` that
+ then fails into the walk's own ``except`` and yields the entry as a leaf — so every
+ unclassifiable entry arrives at the consumers in the shape they already handle, and
+ the gate names it.
+
+ The fault is not exotic. A directory readable but NOT searchable — mode ``0o444``,
+ or ``chmod -R a-x`` — lets ``iterdir()`` succeed and then refuses the ``stat`` on
+ every child; measured, that took ``provision_worktree`` down with ``PermissionError``
+ on 3.11/3.12/3.13 while 3.14 answered False and dropped the subtree silently. Mode
+ ``0o555`` is unaffected, and the suite carries a witness for each so the two are not
+ confused again.
+
+ What the report can say degrades with what the filesystem allows, which is honest in
+ both directions: a directory that cannot be LISTED is named as one rel, because
+ nobody can say which files are short; one that can be listed but not stat'd through
+ is named file by file, because ``iterdir`` did give up the names.
+
+ ⚠️ Unlike :func:`_is_file` this CANNOT be a bare ``try``/``except``, because the two
+ interpreter readings do not agree on which answer is the safe one: ≤3.13 raises
+ where 3.14 returns ``False``, and here ``False`` IS the silent collapse rather than
+ the cautious reading. Measured on the wrapper alone, both ``0o444`` witnesses passed
+ on 3.11/3.12/3.13 and FAILED on 3.14 — the seed dropped the subtree and the gate
+ reported nothing, which is the pre-fix 3.14 behaviour with the ``except`` merely
+ made unreachable. :func:`_probe_refused` is what makes the four lanes agree.
+ """
+ try:
+ if path.is_dir():
+ return True
+ except OSError:
+ return True
+ return _probe_refused(path)
+
+
+# What ``pathlib`` itself reads as "this path is simply not there" when a probe fails.
+# A local copy of its ``_IGNORED_ERRNOS``, which is private and moved in 3.13. Anything
+# outside it — ``EACCES`` above all — is the filesystem REFUSING to answer, which is a
+# different fact and the one :func:`_is_dir` must not swallow.
+_ABSENCE_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP)
+
+
+def _probe_refused(path) -> bool:
+ """True when a ``False`` out of ``is_dir()`` means REFUSED rather than ABSENT.
+
+ 3.14 made ``is_dir()``/``is_file()``/``exists()`` total by answering ``False`` for
+ a fault the older interpreters raise on, which collapses "there is nothing here"
+ into "we were not allowed to look" — and :func:`_is_dir` exists precisely to keep
+ those two apart. ``stat()`` is the probe that still tells them apart on every
+ supported version: measured on 3.11/3.12/3.13/3.14, ``Path.stat()`` raises
+ ``PermissionError`` on an entry whose parent is unsearchable while 3.14's
+ ``is_dir()`` answers ``False``, so re-asking a ``False`` of ``stat()`` recovers the
+ distinction without special-casing the version.
+
+ Only real ``Path`` sources are re-asked. The walk yields wheel Traversables too;
+ a zip-imported tree has no ``stat`` and no permission fault to find (the same
+ reason its cycle guard skips them — see :func:`_walk_traversable_files`).
+ """
+ if not isinstance(path, Path):
+ return False
+ try:
+ path.stat()
+ except OSError as exc:
+ return exc.errno not in _ABSENCE_ERRNOS
+ except ValueError:
+ # An invalid path (embedded null): `is_dir()` swallows it on every lane, and so
+ # does this. Not a refusal, and re-raising would take provisioning out by the
+ # one door it must not use.
+ return False
+ return False
+
+
+def _occupied(path: Path) -> bool:
+ """True when ``path``'s slot is taken by ANYTHING — including a dangling symlink.
+
+ ``Path.exists()`` follows symlinks, so a dangling one answers False while the name
+ is very much taken. Writing anyway does one of two measured things, neither of them
+ the intended copy: when the link resolves to a nonexistent path INSIDE the worktree
+ ``shutil.copy2`` follows it and lands the bytes at the link's target instead of at
+ the slot, leaving an untracked file for the unit's ``git add -A`` to commit while
+ the completeness gate reads green through the now-live link; when the dangling link
+ is an intermediate DIRECTORY component instead, ``mkdir(parents=True,
+ exist_ok=True)`` raises ``FileExistsError`` (its recovery is ``if not exist_ok or
+ not self.is_dir(): raise``, and ``is_dir()`` is False on a dangling link).
+
+ Treating it as occupied keeps provisioning's one promise — never clobber what the
+ checkout carries — for a committed symlink whose target does not exist on this
+ machine, which is a thing git stores and checks out happily. The gates then name
+ the rel, because ``is_file()`` is False through a dangling link too, and the
+ escalation prose already reaches for that exact cause.
+
+ What the ``try`` buys is TOTALITY, not the answer inside it: ``exists()`` raises on
+ an unreadable parent through 3.13 (3.14 answers False — see :func:`_is_file`), and
+ that raise would leave provisioning by the one door it must not use. Answering
+ ``True`` there is the conservative reading — a slot we cannot even stat is not one
+ to write through, and skipping something writable costs a reported rel where writing
+ through something we misread costs a file — but it is not observably different from
+ ``False`` through any caller: the write that would follow is into the very directory
+ that could not be stat'd, so it fails into the caller's leaf ``except`` and the gate
+ names the same rel either way. The witnesses assert that outcome rather than this
+ return value, which is also why they hold on 3.14, where the ``except`` is dead.
+ """
+ try:
+ return path.exists() or path.is_symlink()
+ except OSError:
+ return True
+
+
+def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = None) -> None:
+ """Copy a resource tree into a worktree, copy-when-absent per FILE.
+
+ The merge half of :func:`_copy_traversable`, and the same reason
+ :func:`_seed_bmad_tree` merges rather than replaces: a skill dir is one
+ multi-file unit, so a DIRECTORY-level skip is silent and total — a checkout
+ carrying one file of that dir would receive zero bytes of the rest, pass a
+ directory-granular completeness check, and dispatch a session whose step files
+ are absent. Walks :func:`_walk_traversable_files` rather than ``rglob`` so a
+ zip-imported wheel works, and creates parents lazily at the leaf so an empty
+ source dir is not recreated.
+
+ ``dst`` containment is always checked: a symlink in the CHECKOUT pointing out of
+ the worktree must not be written *through*. ``repo_root`` is passed only for
+ real-filesystem sources, where a child symlinked to a shared install outside the
+ repo must not be read through — wheel package data has no such leg to check, and
+ is not a ``Path`` to resolve in the first place.
+
+ **Every reason one file cannot land is a per-file ``continue``, never a raise.**
+ Provisioning has no failure vocabulary of its own; the honesty comes from
+ :func:`base_skills_seed_incomplete` re-asking the RESULT afterwards, which the
+ engine turns into a named CRITICAL escalation — ``Phase.ESCALATED`` plus notify
+ plus ``RunPaused``, unconditionally, with every rel in the reason line. A raise
+ bypasses all of it: measured, an ``OSError`` here escapes ``Engine._run_isolated``
+ uncaught (the one ``try`` above the provision catches ``verify.GitError``), past
+ the completeness gate that would have named the file, and ends the whole run with
+ ``crash.txt`` and a half-provisioned worktree. The pause is resumable and says what
+ to fix; the crash is neither. Three legs enforce that, and they fail at three
+ different calls:
+
+ - :func:`_is_file` — a source that is not a confirmed regular file has no bytes to
+ deliver. A dangling symlink raises ``FileNotFoundError`` out of ``shutil.copy2``;
+ a FIFO raises ``SpecialFileError`` (``copyfile`` stats both ends before opening
+ either, so this is a crash, not the unbounded read a FIFO causes where something
+ calls ``read_text`` on it).
+ - :func:`_occupied` — the destination slot is taken, dangling symlinks included.
+ - ``try/except OSError`` around the write — an unreadable source file, an
+ unwritable destination, an intermediate dangling-symlink directory, a source
+ that vanished mid-walk. This one is not redundant with the other two: measured,
+ ``target.is_symlink()`` is False when it is the target's PARENT that dangles, so
+ the ``mkdir`` still raises and only the ``except`` catches it.
+
+ The asymmetry with the gate is deliberate and is exactly one predicate wide. This
+ filters :func:`_is_file`; :func:`_absent_skill_files` filters ``_is_file or
+ _is_dir``. The difference is a directory the walk could not see inside: not
+ deliverable, so the copier skips it, and genuinely missing from the worktree, so
+ the gate names it.
+
+ ⚠️ Bounded: only the ``BASE_SKILLS`` caller has a completeness gate behind it. A
+ per-file skip on the ``MODULE_SKILLS`` (wheel) leg is not reported by anything —
+ pre-existing, and the reporting channel belongs to the phase that owns ``skipped``.
+ """
+ for rel, child in _walk_traversable_files(src):
+ if not _is_file(child):
+ continue
+ target = dst.joinpath(*rel.split("/"))
+ if repo_root is not None and not Path(str(child)).resolve().is_relative_to(repo_root):
+ continue
+ if not target.resolve().is_relative_to(worktree) or _occupied(target):
+ continue
+ try:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ _copy_traversable(child, target)
+ except OSError:
+ continue
+
+
def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None:
- """Add anchored ignore patterns to the worktree's local git exclude so the
- provisioned tool files are never staged by the unit's `git add -A`. Uses
- git's standard local-only exclude (never committed or pushed); it does not
- affect already-tracked files. Best-effort — skipped if git can't be queried.
+ """Add anchored ignore patterns to the git exclude that covers the worktree, so the
+ provisioned tool files are never staged by the unit's `git add -A`. Uses git's
+ standard local-only exclude (never committed or pushed); it does not affect
+ already-tracked files. Best-effort — skipped if git can't be queried.
+
+ ⚠️ "Local" is not "private". `info/exclude` lives under `--git-common-dir`, which a
+ linked worktree SHARES with the main checkout (measured: `rev-parse
+ --git-common-dir` from inside a linked worktree answers the main repo's `.git`), and
+ nothing in this codebase ever prunes a line from it. So a pattern written for one
+ story's worktree also hides that path from the operator's own `git status` — and
+ keeps hiding it after the worktree is gone. That is only harmless because every
+ pattern here names a path a bmad-loop project is expected to gitignore anyway
+ (`.claude/`, `.agents/`, `.mcp.json`, `_bmad/`); for a project that deliberately
+ tracks one of them, the shield is over-broad in the main checkout.
+
+ Callers therefore keep lines OUT of this file wherever they can rather than writing
+ every pattern that might apply — see the `_bmad/render/` gate in
+ :func:`provision_worktree`. Making the shield genuinely per-worktree needs a private
+ `/info/exclude` plus a worktree-scoped `core.excludesFile` behind
+ `extensions.worktreeConfig`; that is a repo-format change with its own failure modes
+ and is not something a patch release should introduce.
"""
# Callers pass POSIX-slash patterns (glob rels via as_posix; config strings as
# authored); git's exclude is POSIX-slash on every platform, so nothing to fix here.
@@ -434,6 +1321,545 @@ def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None:
exclude.write_text(prefix + "\n".join(new) + "\n", encoding="utf-8")
+def _is_under_bmad(rel: str) -> bool:
+ """True when a user-authored seed rel names ``_bmad`` or something inside it.
+ Normalized so ``_bmad``, ``_bmad/``, ``./_bmad/custom`` and a Windows-authored
+ ``_bmad\\custom`` all answer the same."""
+ parts = PurePosixPath(rel.replace("\\", "/")).parts
+ return bool(parts) and parts[0] == BMAD_DIR
+
+
+def _seed_bmad_tree(worktree: Path, repo_root: Path) -> list[str]:
+ """Merge-copy the main repo's ``_bmad/`` config surface into a worktree.
+
+ Sessions run with the worktree as their cwd, and the renderer-era dev
+ primitive (BMAD-METHOD #2601) shells out to ``_bmad/scripts/render_skill.py``
+ with a project root that must contain a ``_bmad/`` directory: the renderer
+ hard-fails when it does not, and it does **not** walk up. Projects commonly
+ gitignore ``_bmad/`` (this repo does) and a worktree checks out tracked files
+ only, so under ``isolation = "worktree"`` the checkout has none — the stub
+ then HALTs before the workflow's own HALT protocol is loaded and every story
+ becomes a result-less Stop.
+
+ Whole-directory per-FILE merge, never a curated file list: ``render_skill.py``
+ bare-imports its sibling ``config_utils`` off ``sys.path[0]``, so
+ ``_bmad/scripts/`` is one multi-file unit and a partial seed raises a bare
+ ``ModuleNotFoundError`` above the renderer's own try/except — losing even the
+ ``HALT: `` contract line. The central config is a four-layer stack
+ (``config.toml`` plus the usually-gitignored ``config.user.toml`` and
+ ``custom/`` layers) for the same reason.
+
+ Copy-when-absent, so a checkout that commits its ``_bmad/`` keeps every
+ tracked file untouched and only the missing (gitignored) layers are filled in
+ — nothing new is ever merged back. :data:`BMAD_SEED_EXCLUDES` entries are
+ skipped *before* descending. The resolve-and-contain guard is the seed_files
+ loop's, so neither a symlink nor a ``..`` component can read outside the repo
+ or write outside the worktree.
+
+ Walks the skills copier's :func:`_walk_traversable_files` rather than ``rglob``, so
+ ``_bmad/`` gets the seed semantics the skill trees already have. ``rglob`` does not
+ descend a symlinked SUB-directory, which is how a shared BMAD install is commonly
+ wired one level in (``_bmad/scripts/lib -> /opt/...``): measured, the whole subtree
+ was copied as nothing while :func:`_bmad_scripts_seed_incomplete` — mirroring the
+ same rglob — agreed that nothing was missing. The under-seed and its own detector
+ were wrong together, which is why both moved to the shared walk in one change and
+ not one at a time. A sub-directory symlinked to a real dir INSIDE the repo is now
+ genuinely seeded; one pointing outside is still dropped file-by-file by the
+ containment guard, but the gates can now SEE it — ⚠️ which is the whole reporting
+ surface for ``_bmad/``, and it is two rels wide: :data:`RENDERER_SEED_SENTINELS` is
+ ``scripts`` and ``config.toml``. A symlinked-out or unreadable ``_bmad//`` is walked and dropped exactly as before, and nothing names it.
+
+ The per-level ``iterdir()`` here is the walker's, so an unreadable ``_bmad/`` root
+ is the one ``iterdir`` left outside it — hence its own ``try``. Returning ``[]``
+ rather than raising is the same doctrine as the leaf below, and in the renderer era
+ no run reaches provisioning with a ``_bmad/`` it cannot read: the run-start
+ preflight sees the same fault two files in, since ``render_skill.py`` is then not a
+ readable file either. ⚠️ Measured, it says so in two different shapes — on 3.14
+ ``missing_base_skills`` returns ``skills.dev-renderer`` + ``-config`` and refuses;
+ through 3.13 its own bare ``is_file()`` probes raise ``PermissionError`` out of
+ ``validate``/``run`` instead. Loud either way, so nothing proceeds, but the
+ traceback lane is a finding wearing the wrong clothes; :func:`_is_file` is the fix
+ and this phase applied it only where a raise would have escaped *provisioning*.
+ ⚠️ Both preflight legs are era-gated on :func:`_is_renderer_stub`, so a pre-#2601
+ INLINE primitive passes them and reaches provisioning with an unreadable ``_bmad/``
+ — where this returns ``[]`` and both sentinel predicates go quiet. Harmless in that
+ era, which never reads the tree, but it is why the safety here is the era's and not
+ the function's.
+
+ Ordering is by entry NAME at every level, the walker's key. The old code sorted
+ ``Path`` objects at BOTH levels (``sorted(src_root.iterdir())`` over
+ ``sorted(top.rglob("*"))``), so the two levels agreed with each other and disagreed
+ with the rest of the seed: ``Path`` ordering is case-INSENSITIVE on Windows and a
+ bare name sort is not, and a flat ``rglob`` sort interleaves depths besides. This
+ now orders every level the way the skills walk does. Nothing in production reads
+ the order — the sole call site's two consumers are a truthiness test and a set
+ union that is re-``sorted`` before use — but a report should not be sorted two ways
+ in one list.
+
+ Returns the rels to shield from the unit's ``git add -A``: the single
+ ``_bmad`` root when the worktree had none (everything under it is ours), or
+ the individual seeded files when merging into a checkout that already had one
+ — shield exactly what we wrote.
+ """
+ src_root = repo_root / BMAD_DIR
+ if not _is_dir(src_root):
+ return []
+ dst_root = worktree / BMAD_DIR
+ had_bmad = _is_dir(dst_root)
+ seeded: list[str] = []
+ try:
+ tops = sorted(src_root.iterdir(), key=lambda entry: entry.name)
+ except OSError:
+ return []
+ for top in tops:
+ if top.name in BMAD_SEED_EXCLUDES:
+ continue
+ for rel, src in _walk_traversable_files(top, top.name):
+ if not _is_file(src):
+ continue
+ dst = dst_root.joinpath(*rel.split("/"))
+ if not src.resolve().is_relative_to(repo_root) or not dst.resolve().is_relative_to(
+ worktree
+ ):
+ continue
+ if _occupied(dst):
+ continue
+ try:
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ _copy_traversable(src, dst)
+ except OSError:
+ continue
+ seeded.append(f"{BMAD_DIR}/{rel}")
+ if not seeded:
+ return []
+ return [BMAD_DIR] if not had_bmad else seeded
+
+
+def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
+ """True when the repo carries the renderer but the worktree's ``_bmad/scripts/``
+ is missing at least one file the repo's has.
+
+ That seed is the only thing between a renderer-era install and a result-less
+ Stop, and it can come up short *without failing*. The realistic trigger is a
+ **symlinked** ``_bmad/`` or ``_bmad/scripts/`` — how a shared BMAD install is
+ wired. A symlinked ``scripts`` dir is walked when it is the walk's root, so every
+ file under it reaches the resolve-and-contain guard and, resolving outside
+ ``repo_root``, is dropped one by one. The destination lands empty while
+ provisioning otherwise reports success — a partial ``_bmad/scripts/`` is worse than
+ none, because the bare ``config_utils`` import fails above the renderer's
+ try/except and even the ``HALT:`` line is lost.
+
+ Asks :func:`_walk_traversable_files`, the same walk :func:`_seed_bmad_tree` now
+ copies with, for the same reason :func:`_absent_skill_files` shares the skills
+ copier's: a detector that merely *mirrors* the seed with its own traversal can be
+ wrong in the seed's own direction and confirm it. Measured before the change, with
+ both halves on ``rglob``: ``_bmad/scripts/lib`` symlinked to a shared install, or
+ simply unreadable, was copied as nothing AND reported as complete. Two rglobs
+ agreeing is not a check.
+
+ Filters ``_is_file or _is_dir`` where the seed filters :func:`_is_file` alone — the
+ walker's contract, and the one predicate of difference is a directory the walk
+ could not see inside: the seed cannot deliver it and this must not stay quiet
+ about it.
+
+ Reported through :func:`provision_worktree`'s existing skipped-seed return
+ channel, as the :data:`BMAD_SCRIPTS_SEED_REL` entry, rather than raising —
+ provisioning has no failure vocabulary of its own (every containment violation
+ is a bare ``continue``), and the decision this drives is escalate-and-pause plus
+ notify, which belongs to the caller that owns the state machine and the journal.
+
+ This answers only whether the seed came up SHORT — never whether that matters.
+ It cannot tell: the repo-side ``render_skill.py`` probe fires for any project
+ carrying a renderer-era ``_bmad/scripts/``, including one whose resolved dev
+ primitive is a pre-#2601 INLINE ``SKILL.md`` that never reads the renderer at all.
+ So the engine escalates only when :func:`renderer_stub_resolved` also holds; on an
+ inline primitive the short seed is journaled as an ordinary
+ ``worktree-seed-skipped`` and the run proceeds. Where it does hold, this is not a
+ degraded provision the run can carry — it is an environment fault identical for
+ every story, so dispatching would burn the whole backlog on result-less Stops.
+ """
+ if not _is_file(repo_root / RENDERER_SCRIPT_REL):
+ return False
+ scripts = repo_root / BMAD_DIR / "scripts"
+ dst_scripts = worktree / BMAD_DIR / "scripts"
+ return any(
+ (_is_file(src) or _is_dir(src)) and not _is_file(dst_scripts.joinpath(*rel.split("/")))
+ for rel, src in _walk_traversable_files(scripts)
+ )
+
+
+def _absent_skill_files(repo_skill: Path, worktree_skill: Path) -> list[str]:
+ """Every file of one repo skill dir that did not reach the worktree, as POSIX rels.
+
+ Parity with the COPIER, over the copier's own :func:`_walk_traversable_files` —
+ the question is "did the seed deliver what the repo has", not "does the worktree
+ carry a named list". Asking the shared walk rather than an rglob mirror of it is
+ load-bearing, not stylistic: rglob does not descend a symlinked sub-directory, so
+ a ``review-prompts/`` pointed at a shared install would be dropped by the copy and
+ invisible to the check at once.
+
+ **This reverses the rationale this function used to carry**, which rejected parity
+ because it "would halt the whole backlog over a repo-only ``README.md`` no session
+ reads, with no remedy available to the operator". That objection was retired by the
+ per-FILE merge in the same commit that stated it: :func:`_merge_traversable` filters
+ nothing by name, so a repo-only ``README.md`` is *delivered* like everything else
+ and parity stays silent about it. What parity can flag is exactly the set of files
+ the seed FAILED to deliver — a source symlinked out of the repo, a destination
+ symlink escaping the worktree, a destination occupied by a directory — and every
+ one of those has an operator remedy.
+
+ The old named surface (``SKILL.md`` plus the :data:`BASE_SKILLS` markers) was 3 of
+ the 13 files a real ``bmad-build-auto`` install carries, and the other ten are not
+ inert: since BMAD-METHOD #2601 ``workflow.md`` is the renderer's entry point and
+ names its step files as ``[[bmad-snapshot:…]]`` sources, so a dropped one makes
+ ``render_skill.py`` print ``HALT:`` and the session Stop having written nothing —
+ on every story, which is the environment-fault shape this gate reports. Keeping the
+ surface in sync by hand-extending a marker tuple was the maintenance debt; the walk
+ has no list to fall behind.
+
+ :data:`DEV_PRIMITIVE_MARKERS` is untouched by the reversal — it is still the shim
+ detector and still what :func:`missing_base_skills` demands of the repo. Only the
+ worktree gate's question changed.
+
+ The repo-has-it conjunct is now STRUCTURAL: the walk enumerates the repo, so a
+ project whose install predates a file is never asked for something no copy could
+ have delivered. ``is_file`` rather than ``exists`` on the destination: a slot
+ occupied by a directory is not the file the skill needs, and a slot occupied by a
+ DANGLING symlink is not either — ``is_file()`` is False through one, which is what
+ lets :func:`_merge_traversable` decline to write through it and this name the rel.
+
+ The source filter is ``_is_file or _is_dir`` — the copier's :func:`_is_file` plus
+ exactly one more case, and the ONLY yielded entry that can answer the second alone
+ is a directory :func:`_walk_traversable_files` could not see inside (a descendable
+ one is recursed into; a cycle repeat is dropped with no rel). That one predicate is
+ the whole difference between the two halves of the seed, and it is the right
+ difference in both directions:
+
+ - a dangling symlink or a FIFO in the repo is dropped by BOTH, so a broken link the
+ copy could never have followed does not halt the backlog. What the repo's own
+ CONTENT should be is :func:`missing_base_skills`' question at run start
+ (``skills.base-missing``/``-incomplete``/``-shim``, ``skills.dev-renderer-sources``
+ — deliberately un-counted here, since a new check id must not stale this line);
+ this one asks only whether the copy delivered what the repo has.
+ - a directory the walk could not see inside is dropped by the copier and named
+ here, because the repo does have content there and the worktree did not get it.
+ When it could not be LISTED the rel names the directory, which is all anyone
+ honestly knows; when it could be listed but not stat'd through (mode ``0o444``)
+ the rels name the individual children, because ``iterdir`` did give up the names.
+
+ Every probe is total — :func:`_is_file`/:func:`_is_dir` rather than the raw calls —
+ on both sides: the worktree slot can be unreadable, and so can the repo entry the
+ walk just handed back (see :func:`_is_dir`).
+ """
+ return [
+ rel
+ for rel, src in _walk_traversable_files(repo_skill)
+ if (_is_file(src) or _is_dir(src))
+ and not _is_file(worktree_skill.joinpath(*rel.split("/")))
+ ]
+
+
+def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]:
+ """The rels the repo resolves but the worktree does not carry — a whole skill dir,
+ or a required file inside one.
+
+ The skills half of the same shape :func:`_bmad_scripts_seed_incomplete` reports for
+ the renderer, and the one containment leg nothing covered. `provision_worktree`
+ copies BASE_SKILLS from the main repo's tree behind a
+ ``src.resolve().is_relative_to(repo_root)`` guard whose two legs are not alike:
+
+ - the skill is genuinely absent from the repo — the run-start preflight
+ (:func:`missing_base_skills`) already refused the run, so skipping is right;
+ - the skill IS there but is a **symlink to a shared install outside the repo** —
+ how a shared BMad install is wired, and the same trigger as the ``_bmad/`` case.
+ Then the guard drops it while the preflight, which stats through the link,
+ passes. Measured: a symlinked `.claude/skills/bmad-build-auto` leaves
+ `missing_base_skills` empty and `resolve_dev_primitive` naming the primitive,
+ while the worktree receives NOTHING and provisioning reports success.
+
+ The session then dispatches a skill its worktree does not have — the `Unknown
+ command` stall the preflight exists to prevent, reached anyway by a project that
+ passed it. Unlike the renderer sentinels this needs no RENDERER-era gate: an absent
+ dev primitive or review hunter stalls a session whether its SKILL.md renders or is
+ inline, so the answer is the same question at both layers. (The PRIMITIVE-era gate
+ below is a different axis — which of the two skill names this run dispatches.)
+
+ Gated on the REPO having the skill so a project that legitimately lacks one is not
+ reported twice, and on the WORKTREE lacking it so a tracked skill the checkout
+ already carries (which `provision_worktree` deliberately never clobbers) is not
+ reported at all. Returns the rels rather than a bool so the journal and the
+ escalation reason can name which skill went missing — a run pointed at
+ ``_bmad/scripts`` when a review hunter is what vanished is the drift the shared
+ constants exist to prevent.
+
+ Asked per FILE, not per directory (:func:`_absent_skill_files`): a worktree can
+ hold a SHORT skill dir, and a directory-granular check calls that complete and
+ dispatches a session whose step-04 has nothing to read. The per-file question is
+ walk PARITY with the copier, not a named required set — the seed delivers every
+ file the repo has, so the only thing parity can report is a file the seed dropped,
+ and a named list would go stale against upstream every time a step file is renamed
+ (see :func:`_absent_skill_files` for the rationale this reverses). The precondition
+ is ``SKILL.md`` rather than ``is_dir()`` for the same reason in the other direction —
+ a repo dir with no ``SKILL.md`` is not a dispatchable skill (nothing spells it,
+ :func:`resolve_dev_primitive` returns None), so its absence downstream stalls
+ nothing and pausing over it refuses a healthy run. Both legs still stat *through*
+ a symlink, which is the whole reason the gate exists.
+
+ That precondition is where an UNREADABLE repo skill would otherwise be lost, and
+ losing it is worse than crashing on it: it reads as the benign leg — "this project
+ does not have that skill" — and the run walks on into a worktree that has none of
+ it either. So the two are separated explicitly, and the answer is the same on every
+ supported interpreter, which a bare ``is_file()`` is not: measured, that call
+ RAISES ``PermissionError`` through 3.13 and answers ``False`` on 3.14, so this gate
+ would have crashed on every ≤3.13 lane and gone quiet on the two 3.14 lanes (see
+ :func:`_is_file`). ⚠️ The split is reachable for the review HUNTERS; for the dev
+ primitive an unreadable dir resolves the era away from itself one branch above
+ (:func:`resolve_dev_primitive` → None → the legacy default), so the unused-era skip
+ takes it first and this branch never sees it. That is the right answer where a
+ legacy install is dispatchable, and where it is not the preflight refuses — but it
+ is the era filter doing the work there, not this.
+
+ The era resolution above carried the same hazard and is fixed at its source rather
+ than here: :func:`dev_primitive_or_default` runs on every provision, so a bare probe
+ inside :func:`resolve_dev_primitive` made an unreadable ``bmad-build-auto`` dir
+ raise out of ``provision_worktree`` itself (measured, through 3.13). With that probe
+ total, an unreadable NEW dir falls through to a complete LEGACY install — which is
+ dispatchable, so its era is the used one and the unreadable dir is rightly never
+ asked about — or, with no usable legacy, resolves to None and the preflight refuses.
+ ⚠️ The rest of the run-start preflight keeps its bare probes and this phase did not
+ change them. Measured, that is now narrower than it sounds: the dev-primitive lane
+ answers ``skills.base-missing`` on 3.14 AND through 3.13, because the probe above is
+ total. What still raises out of ``validate``/``run`` on ≤3.13 is an unreadable
+ review-HUNTER dir, at :func:`missing_base_skills`' own bare ``SKILL.md`` probe —
+ loud, so no run proceeds on a bad read, but with a traceback where a finding
+ belongs. Nothing on that surface can reach a session; every path that could reach
+ one is total, walk and probes alike (see :func:`_is_dir`).
+
+ Rel granularity follows the cause: the coarse ``/`` when the worktree
+ lacks ``SKILL.md``, because then everything under the dir is missing and three rels
+ for one cause is noise; the fine ``//`` otherwise, because
+ naming a directory the operator falsifies with one ``ls`` teaches them to stop
+ reading the gate. Every consumer treats a rel as an opaque string (an exact-match
+ sentinel filter, an ``_is_under_bmad`` prefix test, a join into a message), so the
+ extra segment splits nothing.
+
+ Third gate, and the one :data:`BASE_SKILLS` cannot supply: the primitive era this
+ run will not invoke. That map is a copy-if-*present* catalog ("every non-bundled
+ skill that MIGHT need copying") listing both era names, because naming both is free
+ when the answer to each is only "copy it if it is there". Read as a requirement set
+ it demands one skill too many: with `bmad-build-auto` installed, every prompt spells
+ it (:func:`dev_primitive_or_default`, the same call `Engine._dev_skill` makes) and a
+ leftover `bmad-dev-auto` shim is never dispatched — so dropping the shim is not a
+ stall, and pausing the whole run over it refuses a project nothing is wrong with.
+ Measured both directions: a repo whose `bmad-build-auto` is a real dir beside a
+ symlinked-out `bmad-dev-auto` passes the preflight, seeds the worktree with every
+ skill a session names, and was still escalated; so was a resolved *legacy* install
+ beside a `SKILL.md`-less `bmad-build-auto` dir, which nothing can resolve and the
+ preflight never stats. Resolving here rather than filtering the caller's list keeps
+ the engine's re-probe a pure function of disk. (It resolves against ``repo_root``
+ while the engine spells the prompt from ``paths.project``. Reading the two as one
+ is sound on every shipped path: both call sites sit under `Engine._run_isolated`,
+ so nothing reaches this function outside worktree isolation, and every CLI
+ entrypoint that constructs an Engine refuses that mode when a project overrides
+ `repo_root` (:func:`bmadconfig.worktree_isolation_conflict`). It holds by those
+ guards rather than by anything checked here — an in-process caller building an
+ Engine directly, as the tests do, is not covered. #414's fix-1, plumbing
+ `project` through provisioning, is a main-line option this release does not take.)
+
+ The other two non-preflight entries stay gated on purpose. ``bmad-review`` is absent
+ from :data:`DEV_BASE_SKILLS` so a pre-merge bmm install keeps validating, but on a
+ merged-lens install the three hunter ids are thin forwarders to it: a worktree that
+ lacks it breaks those forwards, and the repo-has-it conjunct already keeps the
+ pre-merge case silent."""
+ missing: list[str] = []
+ for tree in dict.fromkeys(trees):
+ # Total form: an unresolvable tree yields the legacy name, which the preflight
+ # has already refused the run over — never a second, quieter answer here.
+ unused_era = {DEV_PRIMITIVE_NEW, DEV_PRIMITIVE_LEGACY} - {
+ dev_primitive_or_default(repo_root, tree)
+ }
+ for skill in BASE_SKILLS:
+ if skill in unused_era:
+ continue
+ repo_skill = repo_root / tree / skill
+ worktree_skill = worktree / tree / skill
+ if not _is_file(repo_skill / "SKILL.md"):
+ # Absent from the repo, or there and unreadable — and the two must not
+ # collapse. False here means "this project legitimately lacks the
+ # skill", so answering it for a dir we merely cannot open would turn an
+ # unreadable install into silence. Only the walk can tell them apart:
+ # a directory it could not see inside is the one thing it yields as a
+ # leaf, while an absent path yields one ("", src) pair too — but that
+ # pair answers _is_dir False, so `any` is False and the skip stands.
+ if any(_is_dir(src) for _, src in _walk_traversable_files(repo_skill)):
+ missing.append(f"{tree}/{skill}")
+ continue
+ if not _is_file(worktree_skill / "SKILL.md"):
+ missing.append(f"{tree}/{skill}")
+ continue
+ missing += [
+ f"{tree}/{skill}/{rel}" for rel in _absent_skill_files(repo_skill, worktree_skill)
+ ]
+ return missing
+
+
+def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
+ """True when the repo has ``_bmad/config.toml`` but the worktree did not get one.
+
+ The other half of the surface :func:`_bmad_scripts_seed_incomplete` guards, and
+ silent in exactly the same way: the seed's resolve-and-contain guard drops a file
+ that resolves outside ``repo_root`` with a bare ``continue``, so a *symlinked*
+ ``_bmad/config.toml`` — a real BMAD install shared across checkouts, with the
+ scripts dir a real directory — is never copied, never reported, and the
+ ``_seed_bmad_tree`` return still claims the whole tree was seeded. The repo-side
+ preflight follows the symlink and passes. Measured: no journal line at all.
+
+ ``load_central_config`` reads this layer with ``required=True``, so a worktree
+ without it HALTs before composing anything, on every story.
+
+ Deliberately NOT gated on the renderer era, unlike the scripts predicate: that
+ one gates to bound an rglob and to assert "this is a renderer-era scripts dir",
+ whereas the premise here is one named file's presence in the repo. Provisioning
+ is a pure reporter; :func:`renderer_stub_resolved` in the engine decides era for
+ both sentinels in one place.
+
+ Both probes are the total :func:`_is_file`, and the repo-side one is why: an
+ unreadable ``_bmad/`` root is exactly the fault :func:`_seed_bmad_tree` degrades
+ over, and a bare ``is_file()`` here raised ``PermissionError`` straight back out of
+ ``provision_worktree`` through 3.13 (measured) — the crash the whole cluster exists
+ to prevent, reached two calls after the ``try`` that prevented it. Answering False
+ matches the sibling scripts predicate, which goes quiet on the same fault because
+ ``render_skill.py`` is not a readable file either, and neither silence hides a run:
+ a project whose ``_bmad/`` cannot be read fails the run-start preflight (see
+ :func:`_seed_bmad_tree`).
+ """
+ return _is_file(repo_root / CENTRAL_CONFIG_REL) and not _is_file(worktree / CENTRAL_CONFIG_REL)
+
+
+def worktree_seed_undelivered(
+ worktree: Path,
+ repo_root: Path,
+ seed_files: Sequence[str] = (),
+ seed_globs: Sequence[str] = (),
+ config_paths: Sequence[str] = (),
+) -> list[str]:
+ """The seed rels the repo carries that never reached the worktree (#415).
+
+ The last silent leg of ``provision_worktree``'s copy-when-absent seeding. Its two
+ explicit loops drop an entry with a bare ``continue`` when the resolve-and-contain
+ guard refuses it, and from the caller's side that is indistinguishable from an entry
+ that applied: ``skipped`` reports only the *destination already exists* case. The
+ realistic trigger is the same one the ``_bmad/`` and skills gates were written for —
+ a config the repo carries as a **symlink to something outside it** (a
+ dotfile-managed ``.claude/settings.json``, a shared MCP config, a plugin's skill
+ tree pointing at a shared checkout). The seed then delivers nothing and says nothing.
+
+ Asked of the RESULT rather than accumulated inside the loops, the pattern the three
+ sibling gates established (:func:`base_skills_seed_incomplete`,
+ :func:`_bmad_scripts_seed_incomplete`, :func:`_central_config_seed_incomplete`): a
+ drop by any future path is caught the same way, and a caller cannot forge one — the
+ answer is a pure function of the two trees on disk.
+
+ **The SOURCE side is deliberately not containment-checked, and that asymmetry is the
+ whole point.** ``_is_file``/``_is_dir`` stat *through* a symlink, so a source
+ resolving outside ``repo_root`` still reads as "the repo carries this" — which is
+ exactly the entry the loop refused and nothing named. Re-applying the loop's
+ ``src.is_relative_to(repo_root)`` guard here would make the gate agree with the loop
+ and report nothing at all. The DESTINATION side IS containment-checked, for the
+ opposite reason: when ``worktree/rel`` resolves out of the worktree (a ``..``
+ component, or a checked-out symlink pointing away), the probe would otherwise stat
+ the file the loop refused to write *through* and read it as delivered.
+
+ ``_is_file(x) or _is_dir(x)`` on both sides, because a ``seed_files`` entry may name
+ a directory (``vendor``) as readily as a file — unlike :func:`_absent_skill_files`,
+ whose rels always come from a walk and are always files. What that filter excludes
+ is the decision the copier already made: a dangling symlink is neither file nor
+ directory, so both halves drop it in silence (see
+ ``test_a_dangling_repo_symlink_is_dropped_by_the_copier_and_the_gate``) — a broken
+ link the copy could never have followed is not a report. A FIFO is filtered out here
+ too, but the copier does NOT drop it: the two loops gate on ``src.exists()``, which a
+ FIFO passes, so ``shutil.copy2`` reaches it and raises — the loud case the warning
+ below covers, not a silent one. An *unreadable* source is
+ the other way: :func:`_is_dir` answers True for a refused probe, so it is carried,
+ and it is named. Absent-from-the-repo and unreadable-in-the-repo must not collapse
+ into one silence, the same split :func:`base_skills_seed_incomplete` makes.
+
+ ⚠️ Bounded to the SILENT drops. A copy that *fails* still raises out of
+ ``provision_worktree`` — the two seed loops have no ``try`` around
+ :func:`_copy_traversable`, unlike :func:`_merge_traversable` and
+ :func:`_seed_bmad_tree` — so this gate never runs for that fault rather than
+ reporting it. Loud, not silent, and unchanged here.
+
+ ⚠️ ``config_paths`` names the rels the per-CLI hook step writes ITSELF
+ (``profile.hooks.config_path`` for every non-hookless profile), and for those the
+ destination probe cannot be the question. That step runs after the seed loops and
+ creates the path unconditionally, so a config the loop dropped is answered for by
+ the hook's own bytes and reads as delivered. Every hook config is also a profile
+ seed entry, and for gemini, copilot and antigravity it is the profile's SOLE
+ default seed — so there the false green is the gate's ENTIRE answer. Asked instead
+ of the two things the hook step cannot fake:
+
+ - the SOURCE resolves out of ``repo_root`` — the seed loop's own refusal, and the
+ canonical trigger (a dotfile-managed ``.claude/settings.json``);
+ - the DESTINATION is a symlink, or resolves out of the worktree — the loop refused
+ to write *through* it (see ``provision_worktree``) and the hook step then wrote
+ through it anyway, materialising a dangling target or a file outside the tree.
+
+ The containment half is KEPT rather than replaced: it is not subsumed by
+ ``is_symlink()``. Measured — a worktree whose ``.claude/`` is a live symlink out of
+ the tree leaves a leaf that is not a link and a source that is perfectly contained,
+ and the ordinary probe below already names it. What the branch drops is the
+ EXISTENCE half, the only half the hook write invalidates. The branch REPLACES the
+ probe rather than adding to it, so a destination that escapes containment is not
+ counted twice.
+
+ ⚠️ A LIVE symlinked destination is therefore named here as well as in ``skipped``.
+ Both facts are true at once — the entry no-op'd, and the repo's bytes are not at
+ the named path — and this channel journals rather than pauses, so the cost of
+ saying both is one line.
+
+ ⚠️ This is NOT the ``skipped``/:data:`RENDERER_SEED_SENTINELS` channel and cannot
+ reach the renderer escalation, which reads that list by exact membership. Reported
+ separately because the two facts have different remediations: a *skip* means the
+ checkout already carries the path and the entry is doing nothing, while a *drop*
+ means the repo has something the worktree does not. Rels are echoed in the caller's
+ own spelling — a user-authored ``seed_files`` string verbatim (a Windows-authored
+ ``_bmad\\custom`` included), a glob match as a POSIX rel — matching what the two
+ loops put in ``skipped`` and in the git exclude respectively.
+ """
+ worktree = worktree.resolve()
+ repo_root = repo_root.resolve()
+ rels: list[str] = [str(rel) for rel in seed_files]
+ for pattern in seed_globs:
+ # Re-expanded rather than passed in, so this stays a function of disk. `glob`
+ # never descends a symlinked sub-directory, which is the same match set the
+ # seed loop saw.
+ rels += [m.relative_to(repo_root).as_posix() for m in sorted(repo_root.glob(pattern))]
+ # `Path`, not the raw string: rels are echoed in the CALLER's spelling (a
+ # Windows-authored `.claude\settings.json` included) while a profile's
+ # `config_path` is always POSIX, and the two name the same file.
+ hook_configs = {Path(rel) for rel in config_paths}
+ undelivered: list[str] = []
+ for rel in dict.fromkeys(rels):
+ src = repo_root / rel
+ if not (_is_file(src) or _is_dir(src)):
+ continue
+ dst = worktree / rel
+ if Path(rel) in hook_configs:
+ if (
+ not src.resolve().is_relative_to(repo_root)
+ or not dst.resolve().is_relative_to(worktree)
+ or dst.is_symlink()
+ ):
+ undelivered.append(rel)
+ continue
+ if dst.resolve().is_relative_to(worktree) and (_is_file(dst) or _is_dir(dst)):
+ continue
+ undelivered.append(rel)
+ return undelivered
+
+
def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool:
"""Install the bundled bmad-loop-* skills into each project skill tree.
@@ -508,7 +1934,10 @@ def provision_worktree(
Kept safe against the unit's eventual `git add -A` commit:
- skills + seed files are copied only when ABSENT, so a project that commits its
- own skill tree (e.g. .agents/) or config keeps it untouched (no diff merged back);
+ own skill tree (e.g. .agents/) or config keeps it untouched (no diff merged back).
+ Skill trees are merged per FILE, not per directory: a checkout carrying part of
+ a skill dir keeps every file it has and gets the rest filled in, rather than
+ being skipped whole and dispatched short (see _merge_traversable);
- the hook points at the MAIN repo's already-installed relay via an absolute
path (the relay locates the run dir from $BMAD_LOOP_RUN_DIR, not its own
location), so nothing is written into the worktree's .bmad-loop/;
@@ -520,12 +1949,33 @@ def provision_worktree(
also a hook config_path (.claude/settings.json, .gemini/settings.json) keeps its
real content and just gets the Stop hook merged in, rather than being created empty.
+ The `_bmad/` config surface is merge-seeded from the MAIN REPO too (see
+ _seed_bmad_tree): the renderer-era dev primitive is handed the worktree as its
+ project root and hard-fails when that root has no _bmad/, so a project that
+ gitignores it (most do) would HALT every session. `_bmad/render/` is never
+ seeded, and it gets its own exclude line only when the blanket `/_bmad` one is
+ not going in — either line alone keeps the renderer's in-session rewrite of it
+ from being swept into a story commit.
+
Returns the `seed_files` entries that existed in the repo but were skipped
because the destination already existed — copy-when-absent turned them into
no-ops. The caller journals them: a user-authored `worktree_seed` entry that
- silently copies nothing reads as applied configuration and is not.
+ silently copies nothing reads as applied configuration and is not. The same
+ channel also reports a renderer surface that reached the worktree SHORT — an
+ incomplete `_bmad/scripts/` or an absent `_bmad/config.toml` (see
+ _bmad_scripts_seed_incomplete, _central_config_seed_incomplete) — which is
+ likewise silent otherwise. Those two entries are RENDERER_SEED_SENTINELS and are
+ reserved: a `seed_files` entry spelling one is dropped rather than passed through,
+ so only the checks above can emit them. Whether either is fatal depends on the
+ resolved primitive's era, which provisioning cannot see from a repo root and a
+ list of CLI profiles — the caller decides (see RENDERER_SEED_SENTINELS).
+
+ Entries the two seed loops DROPPED are NOT in this list: a skip and a drop are
+ different facts with different remediations, so the drop report is its own
+ predicate on its own channel (see worktree_seed_undelivered), which the caller
+ asks of the result afterwards.
"""
- if not profiles and not seed_files and not seed_globs:
+ if not profiles and not seed_files and not seed_globs and not (repo_root / BMAD_DIR).is_dir():
return []
worktree = worktree.resolve()
repo_root = repo_root.resolve()
@@ -534,6 +1984,10 @@ def provision_worktree(
# project gitignored MCP/CLI configs: copy from the main repo when absent.
# Resolve-and-contain guards against an `..`/absolute entry escaping either tree.
+ # Both loops drop a refused entry with a bare `continue`; what they dropped is named
+ # afterwards by `worktree_seed_undelivered`, which re-asks the result rather than
+ # accumulating a bit here — see its docstring for why the two guards below cannot be
+ # the report (the source one is the very fault being reported).
seeded: list[str] = []
# Entries that named a real source but whose destination already exists, so
# copy-when-absent made them a no-op. Reported to the caller (this function is
@@ -546,7 +2000,8 @@ def provision_worktree(
skipped: list[str] = []
for rel in seed_files:
src = (repo_root / rel).resolve()
- dst = (worktree / rel).resolve()
+ raw = worktree / rel
+ dst = raw.resolve()
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
continue
if not src.exists():
@@ -554,6 +2009,26 @@ def provision_worktree(
if dst.exists():
skipped.append(str(rel))
continue
+ # Never write THROUGH a link (#405). `dst` is resolved, and non-strict
+ # `resolve()` follows a dangling one, so the `exists()` above asked about the
+ # TARGET and answered "free" — the mkdir and the copy then land at a path
+ # nobody named, inside the worktree, which the exclude below does not cover
+ # and `git add -A` would stage into the story branch. One comparison covers
+ # both legs: a dangling leaf AND a dangling directory component, which
+ # `_occupied(raw)` would miss (the leaf there is neither link nor file).
+ # Sufficient, not necessary: an entry spelling an internal `..` (`a/../b`)
+ # also differs, since PurePath does not normalize `..` and `resolve()` does.
+ # That entry is refused here and NAMED afterwards by the gate, which is the
+ # right answer for a rel that is not a name for the path it appears to be.
+ #
+ # Strictly AFTER the skip arm, not before: `dst != raw` is true of every
+ # symlinked destination, live ones included, so hoisting it would turn the
+ # ordinary copy-when-absent no-op into a silent drop and empty `skipped`.
+ # Refused with a bare `continue` like the guards above, and named afterwards
+ # by `worktree_seed_undelivered` — the link stays dangling, so the gate's
+ # destination probe reports it.
+ if dst != raw:
+ continue
dst.parent.mkdir(parents=True, exist_ok=True)
_copy_traversable(src, dst)
seeded.append(rel)
@@ -566,38 +2041,87 @@ def provision_worktree(
for match in sorted(repo_root.glob(pattern)):
rel = match.relative_to(repo_root)
src = match.resolve()
- dst = (worktree / rel).resolve()
+ raw = worktree / rel
+ dst = raw.resolve()
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
continue
if not src.exists() or dst.exists():
continue
+ # same write-through refusal as the loop above, and it needs its own: this
+ # loop builds its own `dst` from its own `rel` and shares no code with it
+ if dst != raw:
+ continue
dst.parent.mkdir(parents=True, exist_ok=True)
_copy_traversable(src, dst)
# as_posix so the exclude pattern anchors on Windows too (os.sep would not)
seeded.append(rel.as_posix())
+ # The _bmad/ config surface, merged in AFTER the explicit seed loops: a
+ # user-authored seed_files entry is explicit intent and wins on any collision,
+ # and `had_bmad` must be read once those loops have had their say.
+ seeded_bmad = _seed_bmad_tree(worktree, repo_root)
+ if seeded_bmad:
+ # 0.9.0 named `worktree_seed = ["_bmad"]` only to document that it COPIES
+ # NOTHING once any child is tracked (#230) — it was the broken example, never
+ # the workaround. That entry is now a no-op *because the merge already covered
+ # it*, so reporting it would journal worktree-seed-skipped for a seed that
+ # applied.
+ skipped = [rel for rel in skipped if not _is_under_bmad(rel)]
+ # The sentinels are ours to emit, never a user's to forge. A `worktree_seed` entry
+ # spelling one exactly ("_bmad/scripts", "_bmad/config.toml" — both natural things
+ # to write) lands in `skipped` the moment the checkout already carries it, and the
+ # strip above is conditional on the merge having seeded SOMETHING, so a repo that
+ # commits its whole `_bmad/` skips it. The engine reads these by exact membership,
+ # so an exact-string reservation is exactly as strong as the read it protects:
+ # what follows is then the only thing that can put one back.
+ skipped = [rel for rel in skipped if rel not in RENDERER_SEED_SENTINELS]
+ if _bmad_scripts_seed_incomplete(worktree, repo_root):
+ skipped.append(BMAD_SCRIPTS_SEED_REL)
+ if _central_config_seed_incomplete(worktree, repo_root):
+ skipped.append(CENTRAL_CONFIG_REL)
+
# bundled skills into each CLI's skill tree (deduped: codex+gemini share one);
- # never clobber a skill the checkout already carries (tracked or pre-existing).
+ # never clobber a FILE the checkout already carries (tracked or pre-existing).
for tree in dict.fromkeys(p.skill_tree for p in profiles):
tree_dir = worktree / tree
for skill in MODULE_SKILLS:
- dst = tree_dir / skill
- if dst.exists():
- continue
- _copy_traversable(skills_root.joinpath(skill), dst)
+ _merge_traversable(skills_root.joinpath(skill), tree_dir / skill, worktree)
# The orchestrator-driven upstream skills (BASE_SKILLS) are not in the
# wheel; copy them from the MAIN REPO's installed tree (same tree path) so
- # an isolated worktree can still resolve /bmad-dev-auto and the review
- # hunters. Skip silently when the main repo lacks them — the run-start
- # preflight reports it.
+ # an isolated worktree can still resolve the dev primitive (under EITHER
+ # name — BASE_SKILLS lists both eras) and the review hunters.
+ #
+ # Three legs, and only one of them is safe to skip silently. `not is_dir()`
+ # means the repo genuinely lacks the skill, which the run-start preflight
+ # already refused. A containment failure does NOT: the skill is there, as a
+ # symlink to a shared install outside the repo, and the preflight stats
+ # through the link and passes. Nor does a destination that already holds SOME
+ # of the dir — which is why this merges per FILE rather than skipping on the
+ # directory: a checkout carrying one tracked child of a skill tree, or a
+ # `worktree_seed` entry naming a file inside one, would otherwise leave the
+ # rest at zero bytes. Both reported below via `base_skills_seed_incomplete`,
+ # which re-asks the question of the result rather than trusting this loop's
+ # bookkeeping — and asks it of every file the repo carries, walking exactly the
+ # way this loop does, so a containment drop is reported whichever child it hits
+ # rather than only the two marker files. The per-file skips are NOT reported: the
+ # `seed_files` report exists because a USER-AUTHORED entry that copies nothing
+ # reads as applied configuration, and because a directory entry's no-op is
+ # total — the merge is precisely what removes that total-skip hazard, and
+ # naming every already-present file would put hundreds of rels per story into
+ # the channel the renderer sentinels share.
for skill in BASE_SKILLS:
- dst = tree_dir / skill
- if dst.exists():
+ src_root = repo_root / tree / skill
+ if not src_root.resolve().is_relative_to(repo_root) or not _is_dir(src_root):
continue
- src = (repo_root / tree / skill).resolve()
- if not src.is_relative_to(repo_root) or not src.is_dir():
- continue
- _copy_traversable(src, dst)
+ _merge_traversable(src_root, tree_dir / skill, worktree, repo_root)
+
+ # Report whatever the skills copy above could not deliver. Asked of the RESULT,
+ # not accumulated inside the loop, so a skill dropped by any future path is caught
+ # the same way — and so a user `worktree_seed` entry that happens to spell a skill
+ # rel cannot forge one: the engine re-runs this predicate rather than reading these
+ # strings back (the reason RENDERER_SEED_SENTINELS needs its filter above and this
+ # does not).
+ skipped += base_skills_seed_incomplete(worktree, repo_root, [p.skill_tree for p in profiles])
# per-CLI signal-hook registration, baked to the main repo's relay (absolute).
# Hookless profiles (HTTP/SSE transport) have no config to merge.
@@ -630,6 +2154,28 @@ def provision_worktree(
# as the pattern "/", git-excluding the entire worktree.
patterns |= {f"/{p.hooks.config_path}" for p in profiles if not p.hookless}
patterns |= {f"/{rel}" for rel in seeded}
+ patterns |= {f"/{rel}" for rel in seeded_bmad}
+ if f"/{BMAD_DIR}" not in patterns and (worktree / BMAD_DIR).is_dir():
+ # Shielded whether we seeded it or not: the renderer rewrites this dir
+ # DURING the session, long after provisioning. Both conditions exist to keep
+ # lines OUT of this file, and they are one policy rather than two:
+ # `.git/info/exclude` is the COMMON git dir, shared with the main checkout and
+ # never pruned by anything, so every line written here is permanent state in the
+ # operator's own repo. Measured: a `/_bmad` line hides an untracked `_bmad/` from
+ # `git status` in the MAIN checkout, for the life of the repo.
+ #
+ # So: nothing when the worktree can never grow a `_bmad/`, and nothing when the
+ # blanket root line is already going in — `/_bmad` prunes the directory before
+ # git descends, so `/_bmad/render/` beneath it is never consulted (measured with
+ # `check-ignore -v`: both files attribute to the root line). Writing both was the
+ # inconsistency: one sibling gated to avoid polluting a shared file while the
+ # other added a provably inert line to it.
+ #
+ # Read off `patterns` rather than `seeded_bmad`, because the root line has two
+ # producers: the `_bmad/` merge when the checkout had none, and a user
+ # `worktree_seed = ["_bmad"]` entry that copied (0.9.0's documented broken
+ # example, which really does copy when nothing under `_bmad/` is tracked).
+ patterns.add(f"/{RENDER_DIR_REL}/")
_worktree_local_exclude(worktree, sorted(patterns))
return skipped
@@ -727,7 +2273,16 @@ def install_into(
have = set(existing.splitlines())
to_add = [
line
- for line in (".bmad-loop/runs/", ".bmad-loop/cache/", ".bmad-loop/policy.toml")
+ for line in (
+ ".bmad-loop/runs/",
+ ".bmad-loop/cache/",
+ ".bmad-loop/policy.toml",
+ # the renderer's published output: regenerated on every skill entry,
+ # keyed on this machine's absolute project root. Under isolation =
+ # "none" (the default) this line is the only thing keeping it out of
+ # story commits — worktrees get a git exclude instead.
+ f"{RENDER_DIR_REL}/",
+ )
if line not in have
]
if to_add:
diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py
index 32965e9c..85021ff7 100644
--- a/src/bmad_loop/model.py
+++ b/src/bmad_loop/model.py
@@ -160,6 +160,38 @@ class StoryTask:
# user already had on disk are never deleted. None = pre-upgrade run (no
# snapshot); rollback then removes no untracked files at all.
baseline_untracked: list[str] | None = None
+ # the deferred-work ledger's text as of THIS attempt's pre-harvest moment,
+ # persisted so a host death between `_harvest_spec_deferrals` and the
+ # non-fixable-RETRY rollback cannot lose it: the replayed attempt writes these
+ # bytes back rather than guessing from a presence bit. Scoped to the attempt,
+ # not to the `_dev_phase` entry — re-armed every attempt, unlike
+ # baseline_commit/baseline_untracked.
+ #
+ # The split is load-bearing. A `None` TEXT means the ledger did NOT EXIST when
+ # the snapshot was taken, so the restore has to UNLINK (the first-ever harvest
+ # of a project creates the file); that is a different state from "no snapshot
+ # was taken at all, hands off", which the flag below carries. One nullable
+ # field cannot express both.
+ #
+ # Armed at the pre-harvest `_save()` and cleared once the attempt's decision is
+ # acted on (`Engine._disarm_ledger_snapshot`), so a finished story carries no
+ # ledger copy and no later attempt's replay can consume a stale one.
+ pre_harvest_ledger: str | None = None
+ pre_harvest_ledger_captured: bool = False
+ # This attempt's harvest actually FILED an entry, so the ledger's post-baseline
+ # diff is partly the orchestrator's own writing and must not count as the
+ # session's proof of work (`Engine._harvest_gate_exclude`, #405).
+ #
+ # PERSISTED, and that is the whole point: a crash replay re-runs the harvest,
+ # which dedupes against the entries the dead attempt already wrote, so `filed`
+ # comes back EMPTY while those entries are still sitting in the tree. Derived
+ # fresh from `filed` on the replay it would read False and the gate would pass
+ # on the orchestrator's write after all. Read from disk it stays True.
+ #
+ # Set-only within an attempt (`if filed:`), cleared in the same `if not
+ # replayed:` block that arms the snapshot above — which a replay skips, on
+ # purpose, and a fresh RETRY iteration re-enters.
+ harvest_wrote_ledger: bool = False
spec_file: str | None = None
commit_sha: str | None = None
defer_reason: str | None = None
@@ -252,6 +284,9 @@ def to_dict(self) -> dict[str, Any]:
"followup_review_recommended": self.followup_review_recommended,
"baseline_commit": self.baseline_commit,
"baseline_untracked": self.baseline_untracked,
+ "pre_harvest_ledger": self.pre_harvest_ledger,
+ "pre_harvest_ledger_captured": self.pre_harvest_ledger_captured,
+ "harvest_wrote_ledger": self.harvest_wrote_ledger,
"spec_file": self._serialized_spec_file(),
"commit_sha": self.commit_sha,
"defer_reason": self.defer_reason,
@@ -299,6 +334,21 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
if d.get("baseline_untracked") is not None
else None
),
+ # `is not None`, NOT the natural `str(d.get(k, "")) or None`: a persisted
+ # EMPTY string means "the ledger existed and was empty", and it has to
+ # rehydrate as "" — `None` means "no file at all" and would turn the
+ # replay's restore into an UNLINK of a ledger that was there.
+ #
+ # The flag beside it takes the ordinary `bool(d.get(k, False))`, which is
+ # right here and was wrong for the presence bit this pair replaces: there
+ # a missing key had to become a third "unknown" state, here it means "no
+ # snapshot was captured", and hands-off is exactly the safe default for a
+ # task persisted before these fields existed.
+ pre_harvest_ledger=(
+ str(d["pre_harvest_ledger"]) if d.get("pre_harvest_ledger") is not None else None
+ ),
+ pre_harvest_ledger_captured=bool(d.get("pre_harvest_ledger_captured", False)),
+ harvest_wrote_ledger=bool(d.get("harvest_wrote_ledger", False)),
spec_file=d.get("spec_file"),
commit_sha=d.get("commit_sha"),
defer_reason=d.get("defer_reason"),
diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py
index 39726b54..a54eee65 100644
--- a/src/bmad_loop/policy.py
+++ b/src/bmad_loop/policy.py
@@ -26,6 +26,9 @@
ISOLATION_MODES = {"none", "worktree"}
BRANCH_PER_MODES = {"story", "run"}
MERGE_STRATEGIES = {"ff", "merge", "squash"}
+# Accepted `[dev] skill` values — the adapter discriminator, not the invoked skill
+# name (see DevPolicy.skill): the post-rename "bmad-build-auto" speaks the same
+# contract and is resolved on disk, so it is deliberately NOT a separate value here.
DEV_SKILLS = {"bmad-dev-auto"}
# Backend names are registry keys (adapters/multiplexer.py), never paths or
@@ -198,6 +201,13 @@ class DevPolicy:
# it writes no result.json — the GenericDevAdapter synthesizes one from the
# spec the session leaves on disk. The field is retained (rather than inlined)
# as the seam for a future alternative dev skill; see DEV_SKILLS.
+ #
+ # This value is the ADAPTER DISCRIMINATOR ("which contract does the session
+ # speak"), NOT the skill name the prompt invokes. BMAD-METHOD PR #2651 renamed
+ # the skill to "bmad-build-auto" without changing that contract, so the invoked
+ # NAME is resolved from disk per skill tree (install.resolve_dev_primitive) and
+ # the value here deliberately stays "bmad-dev-auto" — a target project must not
+ # have to edit policy.toml to survive an upstream rename.
skill: str = "bmad-dev-auto"
@@ -593,11 +603,22 @@ def _validate_plugin_settings(name: str, raw: dict[str, Any], specs: Any) -> Non
def load(path: Path | None) -> Policy:
- """Load policy from a TOML file; a missing file yields all defaults."""
+ """Load policy from a TOML file; a missing file yields all defaults.
+
+ An undecodable file is a `PolicyError` like a malformed one. `read_text` raises
+ `UnicodeDecodeError`, which is a `ValueError` and not an `OSError`, so left raw it
+ escapes every `except (PolicyError, OSError)` handler in the codebase — and those
+ handlers are the ones whose whole job is to degrade to defaults rather than take
+ the process down. Converting here fixes them all at once instead of asking each
+ to name a second exception type it has no other reason to know about."""
if path is None or not path.is_file():
return loads("")
try:
- return loads(path.read_text(encoding="utf-8"))
+ text = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError as e:
+ raise PolicyError(f"{path}: not valid UTF-8: {e}") from e
+ try:
+ return loads(text)
except PolicyError as e:
raise PolicyError(f"{path}: {e}") from e
diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py
index 688fd0e9..3e0be157 100644
--- a/src/bmad_loop/stories_engine.py
+++ b/src/bmad_loop/stories_engine.py
@@ -346,7 +346,8 @@ def _stories_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
repair leg falls back to the inherited explicit-spec-file resume.
Fresh dispatch:
- ``/bmad-dev-auto Spec folder: . Story id: .``
+ ``/ Spec folder: . Story id: .``
+ (the primitive is disk-resolved — see ``Engine._dev_skill``)
+ (plan-halt leg) `` Halt after planning.``
+ (when ``invoke_dev_with`` non-empty) a newline then its verbatim text.
@@ -361,14 +362,17 @@ def _stories_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
self._reset_spec_for_repair(task)
spec_ref = task.spec_file or task.story_key
return (
- f"/bmad-dev-auto Resume the autonomous dev session on the in-progress "
+ f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress "
f"spec at `{spec_ref}`. The previous session's work failed deterministic "
f"verification; repair the working tree so verification passes without "
f"changing the spec's frozen intent contract. Verification evidence is "
f"in `{feedback}`."
)
entry = self._entry_for(task)
- prompt = f"/bmad-dev-auto Spec folder: {self._spec_folder_rel}. Story id: {task.story_key}."
+ prompt = (
+ f"/{self._dev_skill()} Spec folder: {self._spec_folder_rel}. "
+ f"Story id: {task.story_key}."
+ )
if self._plan_halt_leg(task, entry):
prompt += " Halt after planning."
if entry is not None and entry.invoke_dev_with:
@@ -442,6 +446,7 @@ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None):
spec_folder=folder,
review_enabled=self._dev_review_enabled(),
plan_halt=plan_halt,
+ engine_written=self._harvest_gate_exclude(task),
)
def _run_verify_commands_after_dev(self, task: StoryTask, result_json: dict | None) -> bool:
diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py
index b5e681a2..dad8a3c5 100644
--- a/src/bmad_loop/sweep.py
+++ b/src/bmad_loop/sweep.py
@@ -492,9 +492,19 @@ def _finish_inflight_bundles(self) -> int:
Runs before the ledger is read, so a bundle it closes leaves the open set
and no fresh triage can re-bundle those ids (validate_triage rejects a plan
- whose open_ids disagree with the ledger). A recovered bundle that defers or
- escalates keeps its ids open, where the existing failed_ids filter drops the
- fresh plan's overlapping bundle."""
+ whose open_ids disagree with the ledger).
+
+ A recovered bundle that defers or escalates keeps its ids open, where the
+ existing failed_ids filter drops the fresh plan's overlapping bundle. That
+ holds on both legs, by two different mechanisms, and used to hold on neither
+ (#405): on the DEV leg the close is withheld until the attempt is accepted
+ (`_post_dev_accepted_sync`), so a discarded attempt leaves nothing on disk to
+ survive its rollback; on the REVIEW leg the close is already written — it has
+ to be, `verify_review_bundle` gates on it — so `_reopen_ledger_after_defer`
+ takes it back after `_defer`'s restore has replayed it over the reset.
+
+ Escalation needs neither: it preserves the tree for the human, so the close
+ still names code that exists."""
recovered = 0
for task in list(self.state.tasks.values()):
if task.terminal or not BUNDLE_KEY_RE.match(task.story_key):
@@ -1159,7 +1169,7 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
"""Bundle invocation for the generic bmad-dev-auto dev skill: the self-contained
intent.md (intent + verbatim ledger entries) is handed over as freeform
intent. The orchestrator owns the deferred-work ledger — the skill is told
- not to edit it — and records resolution itself in `_post_dev_state_sync`.
+ not to edit it — and records resolution itself in `_post_dev_accepted_sync`.
On a repair the bundle spec is re-opened first (B6) so step-01 resumes.
A patch-restore re-drive (#2564, #75) must point at the bundle spec
@@ -1172,7 +1182,7 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
if feedback is None:
if task.restore_patch and task.spec_file:
return (
- f"/bmad-dev-auto Resume review of the in-review spec at "
+ f"/{self._dev_skill()} Resume review of the in-review spec at "
f"`{task.spec_file}` for the deferred-work bundle `{bundle_ref}`. "
f"The attempted change was restored onto the working tree after "
f"an intent-gap resolution; review it against the amended spec. "
@@ -1180,7 +1190,7 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
f"resolution."
)
return (
- f"/bmad-dev-auto Implement the deferred-work bundle described in "
+ f"/{self._dev_skill()} Implement the deferred-work bundle described in "
f"`{bundle_ref}` — it carries the intent and the verbatim ledger "
f"entries to resolve. Do NOT edit the deferred-work ledger; the "
f"orchestrator records resolution."
@@ -1188,7 +1198,7 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
self._reset_spec_for_repair(task)
spec_ref = task.spec_file or bundle_ref
return (
- f"/bmad-dev-auto Resume the autonomous dev session on the in-progress "
+ f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress "
f"spec at `{spec_ref}` for the deferred-work bundle `{bundle_ref}`. The "
f"previous session's work failed deterministic verification; repair the "
f"working tree so verification passes without changing the frozen intent "
@@ -1197,11 +1207,49 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
)
def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None:
+ """No-op: a bundle has no sprint-status row, so the base engine's pre-gate
+ sprint advance has nothing to write. The bundle's own bookkeeping — closing
+ the dw ids it owns — used to live here and does not any more; it moved below
+ the artifact gate, into `_post_dev_accepted_sync` (#405)."""
+ return
+
+ def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> None:
"""Generic-path ledger single-writer for bundles. The decoupled
bmad-dev-auto skill does not touch the ledger, so the orchestrator marks
each dw id the bundle owns ``done`` once the bundle's spec reaches the
- terminal status for the current stage. Mirrors the story sprint sync;
- no-op on the legacy path."""
+ terminal status for the current stage. No-op on the legacy path.
+
+ Below the artifact gate, not above it, and that is the whole design (#405).
+ A close is the most expensive engine-side ledger write to leave behind:
+ `deferredwork.open_ids` re-bundles only `open` entries, and the module's one
+ reopen — `mark_open`, driven by `_reopen_ledger_after_defer` below — undoes
+ only a close its own caller wrote, so a `done` id whose code was discarded is
+ invisible to every future sweep unless this same run takes it back: the work
+ is lost, not merely mis-recorded. Written
+ above the gate it outlived its attempt on two legs. The non-fixable RETRY
+ was covered by `_dev_phase`'s pre-harvest ledger snapshot; the DEFER
+ terminus was not, and no snapshot in `_dev_phase` could have covered it,
+ because `_defer` takes its OWN snapshot after this call and writes those
+ bytes back over its `reset --hard` on purpose, to keep harvested findings.
+ Gating on the accepted decision retires both legs at once and needs no
+ revert on any of them.
+
+ Nothing between the old position and this one GATES on the ledger:
+ `verify_dev_bundle` reads the spec path, `_verify_shared_gates` and an
+ in-memory `dw_ids` cross-check, and `[verify] commands` is operator shell.
+ `_harvest_spec_deferrals` is in that span and does read the file, but is
+ insensitive to the reorder: its pre-scan matches the fingerprinted `origin:`
+ across entries of EVERY status, so a status flip cannot change what it files,
+ and `append_entry`'s open-only dedup only ever fires against rows that same
+ harvest just wrote. `verify_review_bundle` DOES require these entries `done`,
+ and it runs later still — with `_verify_review` re-closing immediately ahead
+ of it anyway, for the case where a review session rewrote the ledger.
+
+ One deliberate consequence: the close no longer contributes to the artifact
+ gate's proof-of-work diff (`verify_dev_exclude_relpaths` does not exclude the
+ ledger). A bundle session that finalized its spec but changed no code used to
+ pass that gate on the orchestrator's own write; it now fails it, which is the
+ answer the gate exists to give."""
if not self._generic_dev():
return
spec_file = (result_json or {}).get("spec_file")
@@ -1231,9 +1279,37 @@ def _close_bundle_ledger_when_spec_status(
if marked:
self.journal.append(kind, story_key=task.story_key, dw_ids=marked)
+ def _reopen_ledger_after_defer(self, task: StoryTask) -> None:
+ """Re-open the dw ids THIS bundle closed, when the defer that just ran
+ discarded the code they claim to resolve.
+
+ Keyed on the resolution note rather than on `task.dw_ids` alone, and
+ `deferredwork.mark_open` refuses any entry whose note differs: the operation
+ is "undo my own close", not "reopen this id". A close written by an earlier
+ sweep, by a human, or by the legacy path where the session edits the ledger
+ itself is left standing — none of them is this run's to revoke, and the
+ ledger has no field that could record who reopened what. That note check is
+ also why this has no `_generic_dev()` guard, unlike `_post_dev_accepted_sync`
+ and `_verify_review`'s reclose: those two WRITE, so they need to know which
+ path owns the ledger, while this one only ever undoes a note it recognises.
+
+ Idempotent by construction: a second call finds the entries already `open`
+ and writes nothing, so a resume that re-drives a deferred bundle cannot
+ double-undo. The distinct journal kind makes "a defer took a close back"
+ greppable next to `sweep-bundle-closed` / `sweep-bundle-reclosed`."""
+ ledger = self.workspace.paths.deferred_work
+ note = f"resolved by sweep bundle {task.story_key}"
+ reopened = [i for i in task.dw_ids if deferredwork.mark_open(ledger, i, note)]
+ if reopened:
+ self.journal.append("sweep-bundle-reopened", story_key=task.story_key, dw_ids=reopened)
+
def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None):
return verify.verify_dev_bundle(
- task, self.workspace.paths, result_json, review_enabled=self._dev_review_enabled()
+ task,
+ self.workspace.paths,
+ result_json,
+ review_enabled=self._dev_review_enabled(),
+ engine_written=self._harvest_gate_exclude(task),
)
def _verify_review(self, task: StoryTask):
diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py
index b54930d4..1287d707 100644
--- a/src/bmad_loop/tui/app.py
+++ b/src/bmad_loop/tui/app.py
@@ -178,8 +178,31 @@ def _mux_guarded(self, probe: Callable[[], _T]) -> tuple[bool, _T | None]:
return False, None
def _guarded(self, go: Callable[[], None]) -> None:
- """Pre-launch guard mirroring the CLI: clean worktree required, plus a
- confirm when another engine is already live."""
+ """Pre-launch guard mirroring the CLI: the #414 isolation/repo_root conflict
+ refused first, then a clean worktree required, plus a confirm when another
+ engine is already live."""
+ # The detached CLI refuses this combination too, and it is the authority —
+ # this only turns a pane that dies immediately into a toast. Ordered ahead of
+ # the clean-tree gate for the same reason `cmd_run` orders it ahead: this one
+ # says the configuration cannot run at all, so answering "commit or stash
+ # first" would send the operator to fix something that is not the problem.
+ #
+ # An unreadable config or policy falls through to launch rather than
+ # blocking: the guard cannot tell "no conflict" from "could not look", so it
+ # defers to the CLI, which reads the same two files and fails loudly on
+ # whichever one it cannot parse. Both loaders convert an undecodable file
+ # into their own typed error, so the two named here are the whole surface;
+ # a raw `UnicodeDecodeError` would be a ValueError and escape.
+ try:
+ conflict = bmadconfig.worktree_isolation_conflict(
+ bmadconfig.load_paths(self.project),
+ policy.load(self.project / POLICY_FILE).scm.isolation,
+ )
+ except (bmadconfig.BmadConfigError, policy.PolicyError, OSError):
+ conflict = None
+ if conflict is not None:
+ self.notify(conflict, severity="error")
+ return
try:
if not verify.worktree_clean(self.project):
self.notify(
@@ -217,10 +240,12 @@ def action_start_run(self) -> None:
def _stories_defaults(self) -> tuple[str, str]:
"""The [stories] policy source + spec_folder to prefill the start-run
- modal, or the sprint-mode default when policy is unreadable."""
+ modal, or the sprint-mode default when policy is unreadable — including
+ undecodable, which `policy.load` reports as a `PolicyError` rather than
+ letting a raw `UnicodeDecodeError` past this handler."""
try:
pol = policy.load(self.project / POLICY_FILE)
- except (policy.PolicyError, OSError, ParseError):
+ except (policy.PolicyError, OSError):
return "sprint-status", ""
return pol.stories.source, pol.stories.spec_folder
diff --git a/src/bmad_loop/tui/screens/dashboard.py b/src/bmad_loop/tui/screens/dashboard.py
index de8a7a36..f9260346 100644
--- a/src/bmad_loop/tui/screens/dashboard.py
+++ b/src/bmad_loop/tui/screens/dashboard.py
@@ -179,7 +179,10 @@ def __init__(self, project: Path):
super().__init__()
self.project = project
# Persisted pane sizes to seed on first layout; a malformed or unreadable
- # policy file degrades to defaults rather than blocking the dashboard.
+ # policy file degrades to defaults rather than blocking the dashboard. That
+ # now covers a file whose BYTES are unreadable too — `policy.load` converts
+ # the `UnicodeDecodeError` rather than letting a ValueError past a handler
+ # that runs before the app can draw anything.
try:
self._tui_policy = policy_mod.load(project / policy_mod.POLICY_FILE).tui
except (policy_mod.PolicyError, OSError):
diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py
index 3017dfac..5d65560b 100644
--- a/src/bmad_loop/verify.py
+++ b/src/bmad_loop/verify.py
@@ -159,10 +159,28 @@ def worktree_clean(repo: Path) -> bool:
# count as a "dirty tree" that blocks run/sweep/validate or forces a commit.
# Scope is policy.toml only — the deferred-work ledger also lives under
# .bmad-loop/ and is meant to be committed (see sweep._commit_ledger).
- rc, out = _git(repo, "status", "--porcelain", "--", ".", f":(exclude){POLICY_FILE_REL}")
- if rc != 0:
- raise GitError(f"git status failed in {repo}: {out}")
- return out == ""
+ #
+ # Reads `stdout` alone for the same reason :func:`path_tracked` does: `status`
+ # exits 0 while still writing diagnostics to stderr (a `core.fsmonitor` hook that
+ # cannot exec, an unknown `core.fsyncMethod`), and against `_git`'s merged stream
+ # that chatter is indistinguishable from a porcelain line — reporting a clean
+ # checkout as dirty and blocking run/sweep/validate on a tree with nothing in it.
+ proc = _run_git(
+ [
+ "git",
+ "-C",
+ str(repo),
+ "status",
+ "--porcelain",
+ "--",
+ ".",
+ f":(exclude){POLICY_FILE_REL}",
+ ],
+ repo,
+ )
+ if proc.returncode != 0:
+ raise GitError(f"git status failed in {repo}: {(proc.stdout + proc.stderr).strip()}")
+ return proc.stdout.strip() == ""
def same_commit(a: str, b: str) -> bool:
@@ -279,6 +297,41 @@ def untracked_files(repo: Path) -> set[str]:
return {line.strip() for line in out.splitlines() if line.strip()}
+def path_tracked(repo: Path, rel: str) -> bool:
+ """True when repo-relative posix ``rel`` has an index entry — i.e. git OWNS the
+ path, so a `reset --hard` restores it and no caller should delete it by hand.
+
+ The single-path complement of :func:`untracked_files`, and the pair is what makes
+ the third state legible: neither tracked nor in that set means IGNORED, which no
+ rollback step touches at all (`reset --hard` skips it, and this module never runs
+ `git clean`). A caller that reasons only over "tracked vs untracked" silently
+ files every ignored path under whichever branch it wrote last.
+
+ Only the output's EMPTINESS is read, never its text: `core.quotePath` mangles
+ non-ASCII names, and a tracked-but-deleted-from-the-worktree path still lists (the
+ index entry outlives the file), which is exactly the state a caller must not
+ mistake for "not git's". Not `--error-unmatch`, which reports "not tracked" and
+ "git blew up" with the same non-zero rc; not `check-ignore`, which answers whether
+ a RULE matches rather than whether git owns the path — a `git add -f`'d file under
+ an ignore rule has to read tracked here.
+
+ Reads `stdout` ALONE, not `_git`'s stdout+stderr merge: `ls-files` exits 0 while
+ still writing to stderr — a `core.fsmonitor` hook that cannot exec, an unknown
+ `core.fsyncMethod` — and against the merged stream that chatter reads as an index
+ entry for a path git does not track at all. The failure is silent and inverted
+ (untracked answers "tracked"), so callers act on the opposite of the truth. The
+ error path keeps the merge, where stderr is the only informative half.
+
+ Raises GitError like every other probe in this module. Callers inside a rollback
+ `finally` catch it and degrade toward leaving the file alone: uncertainty must
+ never authorize a delete."""
+ proc = _run_git(["git", "-C", str(repo), "ls-files", "--", rel], repo)
+ if proc.returncode != 0:
+ merged = (proc.stdout + proc.stderr).strip()
+ raise GitError(f"git ls-files -- {rel} failed in {repo}: {merged}")
+ return bool(proc.stdout.strip())
+
+
def commits_above(repo: Path, baseline: str) -> list[str]:
"""Commit shas reachable from HEAD but not from ``baseline`` — the commits an
attempt added on top of its pre-attempt baseline, in ``git rev-list`` order (do
@@ -1255,6 +1308,8 @@ def verify_dev(
paths: ProjectPaths,
result_json: dict[str, Any] | None,
review_enabled: bool = True,
+ *,
+ engine_written: tuple[str, ...] = (),
) -> VerifyOutcome:
"""Verify a dev session's on-disk artifacts against its result.json claims.
@@ -1264,6 +1319,12 @@ def verify_dev(
orchestrator's, has produced changes since that baseline, and that the
story's sprint-status was advanced to the matching stage. Returns a retryable
VerifyOutcome on any mismatch, escalates on git failure, passes otherwise.
+
+ ``engine_written`` names paths the ORCHESTRATOR itself wrote during this
+ attempt, above this gate. Distinct from ``extra_exclude``'s mode-specific
+ artifacts: those are excluded because every session rewrites them as routine
+ bookkeeping, these because the session did not write them at all. See
+ `Engine._harvest_gate_exclude` (#405).
"""
rj = result_json or {}
spec_file = rj.get("spec_file")
@@ -1281,7 +1342,7 @@ def verify_dev(
task,
paths,
expected_status="in-review" if review_enabled else "done",
- extra_exclude=(),
+ extra_exclude=engine_written,
)
if gate is not None:
return gate
@@ -1302,13 +1363,19 @@ def verify_dev_bundle(
paths: ProjectPaths,
result_json: dict[str, Any] | None,
review_enabled: bool = True,
+ *,
+ engine_written: tuple[str, ...] = (),
) -> VerifyOutcome:
"""verify_dev for a deferred-work bundle: bundles have no sprint-status
entry. The orchestrator owns the bundle→dw-id binding (``task.dw_ids``,
marked done by ``SweepEngine``'s ledger sync); the generic ``bmad-dev-auto``
primitive never authors dw ids. So the dw_ids cross-check is enforced only
when the session actually claims them — an empty/absent claim is the normal
- generic path and passes."""
+ generic path and passes.
+
+ ``engine_written``: see :func:`verify_dev`. The bundle leg is the one that
+ needs it most — a close it lets through marks real dw ids ``done`` with no
+ code behind them, and `open_ids` re-bundles only ``open`` entries."""
rj = result_json or {}
spec_file = rj.get("spec_file")
if not spec_file:
@@ -1326,7 +1393,7 @@ def verify_dev_bundle(
task,
paths,
expected_status="in-review" if review_enabled else "done",
- extra_exclude=(),
+ extra_exclude=engine_written,
allow_ancestor_baseline=True,
)
if gate is not None:
@@ -1358,6 +1425,7 @@ def verify_dev_stories(
spec_folder: Path,
review_enabled: bool = True,
plan_halt: bool = False,
+ engine_written: tuple[str, ...] = (),
) -> VerifyOutcome:
"""verify_dev for stories mode: the story spec lives at the id-keyed path
``/stories/-.md`` and there is no sprint-status entry.
@@ -1430,13 +1498,19 @@ def verify_dev_stories(
# on top of the gate's own file-granular exclude — NOT the whole-folder
# artifact_relpaths, so a story whose entire authorized scope is ledger/spec
# reconciliation doesn't register as a false "no changes".
+ #
+ # `engine_written` rides on the same tuple. Nothing to add on the plan-halt
+ # leg: `None` skips the proof-of-work gate outright, so there is no diff for
+ # an orchestrator write to satisfy.
gate = _verify_shared_gates(
spec_path,
rj,
task,
paths,
expected_status=expected,
- extra_exclude=(None if plan_halt else _stories_relpaths(paths.project, spec_folder)),
+ extra_exclude=(
+ None if plan_halt else _stories_relpaths(paths.project, spec_folder) + engine_written
+ ),
)
if gate is not None:
return gate
diff --git a/tests/conftest.py b/tests/conftest.py
index 274fc56e..d5b267ea 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -109,6 +109,28 @@ def _exists_run(marker: str) -> str:
return f'test -f "{_RUN}/{marker}"'
+def passes_once(marker) -> str:
+ """Shell verify command exiting 0 the FIRST time it runs and 1 every time after,
+ by latching `marker` — the only way to make one `[verify] commands` gate pass at
+ dev time and fail at review time, which is what separates the two gates within a
+ single run.
+
+ Takes an absolute path rather than the `$BMAD_LOOP_RUN_DIR` its neighbours above
+ use: `verify.run_verify_commands` passes no env to the subprocess, so that
+ variable is unset here and the command would latch at the filesystem root. Pass
+ something outside the worktree, or a rollback's untracked cleanup will eat the
+ marker between the two runs and the command passes twice.
+
+ Written for cmd as well as sh for the same reason as its neighbours: CI runs the
+ whole suite on windows-latest, where `shell=True` is `cmd.exe /c`, and a POSIX
+ `test`/`$(…)` command there is silently MIS-PARSED into a constant exit code
+ rather than rejected — so the test fails on the lane no one runs locally."""
+ if sys.platform == "win32":
+ win = str(marker).replace("/", "\\")
+ return f'if exist "{win}" (exit 1) else (type nul > "{win}")'
+ return f'test ! -f "{marker}" && touch "{marker}"'
+
+
def _seeded_then_touch(rel: str, marker: str) -> str:
if sys.platform == "win32":
norm_rel = rel.replace("/", "\\")
@@ -272,14 +294,131 @@ def install_dev_base_skills(root: Path, tree: str = ".claude/skills", *, folder_
def install_base_skills(paths: ProjectPaths, trees=(".claude/skills", ".agents/skills")) -> None:
"""Stub every non-bundled upstream skill (`install.BASE_SKILLS` — a superset of
DEV_BASE_SKILLS that also covers what a worktree mount must copy) in each of a
- sandbox project's active CLI skill trees. Sprint mode drives any bmad-dev-auto,
- so no folder+id probe is written."""
+ sandbox project's active CLI skill trees. Sprint mode drives any dev primitive,
+ so no folder+id probe is written.
+
+ BASE_SKILLS names BOTH primitive eras, so this lays down bmad-build-auto AND
+ bmad-dev-auto — a state no real bmm install produces, but the point of this
+ scaffold is "nothing the orchestrator may copy is absent". Resolution therefore
+ picks bmad-build-auto here; use `install_dev_base_skills` for a pre-rename
+ project and `install_dev_shim` for a post-rename one."""
from bmad_loop.install import BASE_SKILLS
for tree in trees:
_write_skill_stubs(paths.project / tree, BASE_SKILLS)
+BUILD_AUTO_STEPS = (
+ "step-01-clarify-and-route.md",
+ "step-02-plan.md",
+ "step-03-implement.md",
+ "step-04-review.md",
+)
+# What a renderer-stub SKILL.md looks like since BMAD-METHOD #2601: it delegates to
+# the project-local render script instead of carrying the prompt inline.
+RENDERER_STUB_SKILL_MD = (
+ "# bmad-build-auto\n\nRun:\n\n```bash\nuv run _bmad/scripts/render_skill.py "
+ "bmad-build-auto\n```\n"
+)
+# How upstream's render_skill.py actually opens: a plain module-scope import of its
+# sibling off sys.path[0] — no try, no path fixup — so the helper's absence is a bare
+# ModuleNotFoundError. The preflight keys the sibling's requirement on this text, so a
+# scaffold wanting the REAL two-file unit must carry it; a bare "# renderer" body
+# models the other era, a renderer with no sibling to lose.
+RENDERER_SCRIPT_IMPORTING_SIBLING = (
+ "from config_utils import ConfigError, load_central_config\n\n\ndef main():\n"
+ " load_central_config('.')\n"
+)
+# The renderer's ENTRY document, the half a stub SKILL.md does not carry: since #2601
+# `render_skill.py` composes the real prompt from `workflow.md` and hard-fails when it
+# is absent (`render entry is missing`) or when a `[[bmad-snapshot:…]]` token names a
+# file outside the skill's own source set (`snapshot reference targets undeclared
+# source`) — both printed as `HALT:`, i.e. a result-less Stop on every story.
+#
+# The token names `step-04-review.md` deliberately: it is a DEV_PRIMITIVE_MARKER, so
+# every fixture that lays down a renderer-era stub already carries the target and stays
+# a HEALTHY install. A fixture modelling a BROKEN one writes its own body.
+RENDERER_WORKFLOW_MD = (
+ "# bmad-build-auto\n\nRead fully and follow: [[bmad-snapshot:step-04-review.md]]\n"
+)
+
+
+def install_build_auto_skill(
+ root: Path,
+ tree: str = ".claude/skills",
+ *,
+ folder_id: bool = True,
+ renderer_stub: bool = False,
+) -> Path:
+ """Lay down a realistic POST-rename dev primitive (`bmad-build-auto`) under
+ ``root/tree``: SKILL.md, all four step files, customize.toml with prompt-file
+ layers, and a review-prompts/ payload.
+
+ Richer than `_write_skill_stubs` on purpose — the marker files alone can't show
+ that a worktree mount carries a skill's *subdirectories*, and `renderer_stub`
+ needs a SKILL.md whose content is the thing under test. ``folder_id`` writes the
+ dispatch marker into step-01 (stories mode's content probe); ``renderer_stub``
+ swaps SKILL.md for the #2601 renderer stub AND adds the `workflow.md` entry that
+ stub composes from — the two are one era, and an install with the stub but no entry
+ is a broken one, not a variant. Which makes ``renderer_stub=False`` the pre-#2601
+ INLINE era and this fixture its own era control. Returns the skill dir."""
+ from bmad_loop.install import DEV_PRIMITIVE_NEW, STORIES_PROBE_FILE, STORIES_PROBE_TEXT
+
+ skill = Path(root) / tree / DEV_PRIMITIVE_NEW
+ (skill / "review-prompts").mkdir(parents=True, exist_ok=True)
+ (skill / "SKILL.md").write_text(
+ RENDERER_STUB_SKILL_MD if renderer_stub else f"# {DEV_PRIMITIVE_NEW}\n", encoding="utf-8"
+ )
+ if renderer_stub:
+ (skill / "workflow.md").write_text(RENDERER_WORKFLOW_MD, encoding="utf-8")
+ for step in BUILD_AUTO_STEPS:
+ body = f"# {step}\n"
+ if folder_id and step == STORIES_PROBE_FILE:
+ body = f"This is a **{STORIES_PROBE_TEXT}** router.\n"
+ (skill / step).write_text(body, encoding="utf-8")
+ (skill / "customize.toml").write_text(
+ '[[review_layers]]\nid = "adversarial"\nprompt_file = "review-prompts/adversarial.md"\n',
+ encoding="utf-8",
+ )
+ (skill / "review-prompts" / "adversarial.md").write_text("# adversarial\n", encoding="utf-8")
+ return skill
+
+
+def install_dev_shim(root: Path, tree: str = ".claude/skills") -> Path:
+ """Lay down the post-rename FORWARDING SHIM: a lone `bmad-dev-auto/SKILL.md`
+ with no step files and no customize.toml.
+
+ This is what upstream leaves behind after the bmad-build-auto rename, and its
+ migration gate is interactive — an unattended session dispatched to it HALTs
+ without writing anything. Writing only SKILL.md is the whole point: the absent
+ markers are what the shim detector keys on. Returns the shim dir."""
+ from bmad_loop.install import DEV_PRIMITIVE_LEGACY
+
+ shim = Path(root) / tree / DEV_PRIMITIVE_LEGACY
+ shim.mkdir(parents=True, exist_ok=True)
+ (shim / "SKILL.md").write_text(
+ f"# {DEV_PRIMITIVE_LEGACY}\n\nThis skill has been renamed to bmad-build-auto.\n",
+ encoding="utf-8",
+ )
+ return shim
+
+
+def attach_profile(adapter, name: str = "claude", project: Path | None = None):
+ """Give a scripted adapter the ``profile`` a real CLI adapter carries, so the
+ seams that read ``adapter.profile.skill_tree`` — chiefly ``Engine._dev_skill``,
+ which resolves the invoked dev-primitive NAME off disk — see a real skill tree.
+
+ `MockAdapter` deliberately has no `profile` at all, and that is not an
+ oversight to paper over globally: the profile-less shape IS the None-tree
+ fallback path (legacy name), so it stays the default and gets pinned by its
+ own test. Attach only where the resolved name is what's under test. Returns
+ the adapter for chaining."""
+ from bmad_loop.adapters.profile import get_profile
+
+ adapter.profile = get_profile(name, project)
+ return adapter
+
+
def fault_read_text(monkeypatch, target: Path) -> None:
"""Make exactly ``target``'s ``read_text`` raise PermissionError; every other
path still reads normally. A selective monkeypatch rather than chmod: chmod is a
@@ -308,15 +447,70 @@ def set_sprint(paths: ProjectPaths, key: str, status: str) -> None:
paths.sprint_status.write_text(yaml.safe_dump(doc, sort_keys=False))
-def write_spec(path: Path, status: str, baseline: str, *, prose_status: str | None = None) -> None:
+def render_deferred(items) -> str:
+ """Render a frontmatter `deferred:` block the way bmad-build-auto's step-04
+ writes one (BMAD-METHOD #2640): a YAML list whose free-form values are block
+ scalars — `>-` folds the single-line summary/location, `|-` keeps evidence
+ verbatim — so a `:`, `#`, quote, or line break inside a finding stays data.
+
+ Rendering the real block scalars (rather than plain `key: value`) is the
+ point: the harvest reads through `verify.read_frontmatter`, and a fixture
+ that quoted its values would prove only that YAML parses quoted strings.
+
+ A dict item renders that shape, omitting keys it does not carry — so
+ `{"evidence": ...}` alone is the missing-summary malformed case. A plain
+ string item renders as a bare scalar list entry: the not-a-mapping malformed
+ case. Both exist so a test can put malformed items NEXT TO good ones."""
+ if not items:
+ return "deferred: []\n"
+ lines = ["deferred:"]
+ for item in items:
+ if not isinstance(item, dict):
+ lines.append(f" - {item}")
+ continue
+ rendered = False
+ for key in ("summary", "evidence", "location", "severity"):
+ if key not in item:
+ continue
+ lead = " " if rendered else " - "
+ rendered = True
+ text = str(item[key])
+ if not text:
+ lines.append(f"{lead}{key}: ''") # a block scalar cannot be empty
+ continue
+ if key == "severity":
+ lines.append(f"{lead}{key}: {text}") # a plain enum token upstream
+ continue
+ lines.append(f"{lead}{key}: {'|-' if key == 'evidence' else '>-'}")
+ lines += [f" {ln}" for ln in text.splitlines()]
+ if not rendered:
+ lines.append(" - {}")
+ return "\n".join(lines) + "\n"
+
+
+def write_spec(
+ path: Path,
+ status: str,
+ baseline: str,
+ *,
+ prose_status: str | None = None,
+ deferred=None,
+) -> None:
"""Write a spec the way the real bmad-dev-auto skill does. The skill's step-03
stamps `baseline_revision` and NEVER `baseline_commit` (that name exists only
in the orchestrator's synthesized result.json), so this fixture stamps the
same key — a reader that only knows `baseline_commit` must fail a test here,
- not sail through production (issue #89)."""
+ not sail through production (issue #89).
+
+ ``deferred`` adds the post-#2640 frontmatter `deferred:` list (see
+ `render_deferred`); ``None`` — the default — omits the field entirely, which
+ is both the pre-#2640 spec shape and the overwhelmingly common post-#2640 one
+ (nothing was deferred). Pass ``[]`` for an explicitly empty list."""
body = (
f"---\ntitle: 'test'\ntype: 'feature'\nstatus: '{status}'\n"
- f"baseline_revision: '{baseline}'\n---\n\n## Intent\n\ntest spec\n"
+ f"baseline_revision: '{baseline}'\n"
+ f"{render_deferred(deferred) if deferred is not None else ''}"
+ f"---\n\n## Intent\n\ntest spec\n"
)
if prose_status is not None:
# mirror bmad-dev-auto's terminal finalize: it appends a `## Auto Run
@@ -387,6 +581,7 @@ def dev_effect(
prose_status: str | None = None,
seen: list[str] | None = None,
write_src: bool = True,
+ deferred=None,
):
"""Simulate a successful bmad-dev-auto session: it self-finalizes the spec
(no in-review handoff — always straight to ``done``) but never touches the
@@ -404,7 +599,11 @@ def dev_effect(
patch-restore tests assert the re-driven session ran against the RESTORED diff.
``write_src=False`` then keeps the session from appending its own line, so what
lands in the tree is exactly what the restore laid down (the applied patch is
- the session's proof of work; a second edit would muddy the assertion)."""
+ the session's proof of work; a second edit would muddy the assertion).
+
+ ``deferred`` records review findings in the spec's frontmatter the way the
+ post-#2640 skill does (the orchestrator harvests them into the ledger); the
+ session still never touches `deferred-work.md` itself."""
def effect(spec: SessionSpec) -> SessionResult:
baseline = rev_parse_head(paths.project)
@@ -414,7 +613,7 @@ def effect(spec: SessionSpec) -> SessionResult:
if write_src:
source.write_text(source.read_text() + f"change for {story_key}\n")
sp = spec_path(paths, story_key)
- write_spec(sp, final_status, baseline, prose_status=prose_status)
+ write_spec(sp, final_status, baseline, prose_status=prose_status, deferred=deferred)
# deliberately NO set_sprint: the dev skill does not write sprint-status
return SessionResult(
status="completed",
@@ -561,6 +760,8 @@ def bundle_dev_effect(
followup_review: bool = True,
final_status: str = "done",
prose_status: str | None = None,
+ deferred=None,
+ write_src: bool = True,
):
"""Simulate a bmad-dev-auto bundle dev session: edits code and self-finalizes
the bundle spec to ``done`` (no in-review handoff). On the decoupled path the
@@ -568,16 +769,21 @@ def bundle_dev_effect(
``mark_ledger=True`` is kept only for the legacy-marking path in older tests.
``followup_review`` mirrors `followup_review_recommended` — defaults True so
the bundle review runs under the default trigger = "recommended". ``final_status``
- / ``prose_status`` mirror ``dev_effect``: pair a non-terminal ``final_status``
- with ``prose_status="done"`` to reproduce the skill finalizing in prose only."""
+ / ``prose_status`` / ``deferred`` / ``write_src`` mirror ``dev_effect``: pair a
+ non-terminal ``final_status`` with ``prose_status="done"`` to reproduce the skill
+ finalizing in prose only; ``deferred`` records post-#2640 frontmatter findings;
+ ``write_src=False`` keeps the session from touching code at all, which is the only
+ way to express a bundle session whose whole post-baseline diff belongs to the
+ orchestrator (the harvest, or the ledger close) rather than to the session."""
def effect(spec: SessionSpec) -> SessionResult:
baseline = rev_parse_head(paths.project)
source = paths.project / "src.txt"
- source.write_text(source.read_text() + f"change for dw-{name}\n")
+ if write_src:
+ source.write_text(source.read_text() + f"change for dw-{name}\n")
sp = bundle_spec_path(paths, name)
# mirror the skill: always self-finalize the bundle spec straight to done
- write_spec(sp, final_status, baseline, prose_status=prose_status)
+ write_spec(sp, final_status, baseline, prose_status=prose_status, deferred=deferred)
if mark_ledger:
mark_ledger_done(paths, dw_ids)
return SessionResult(
diff --git a/tests/test_bmadconfig.py b/tests/test_bmadconfig.py
index b71c1c16..b5c36f74 100644
--- a/tests/test_bmadconfig.py
+++ b/tests/test_bmadconfig.py
@@ -89,3 +89,19 @@ def test_workspace_default_uses_repo_root(tmp_path: Path) -> None:
ws = Workspace.default(paths)
assert ws.root == tmp_path / "repo"
assert ws.paths is paths
+
+
+def test_worktree_isolation_conflict_compares_normalized_paths(tmp_path: Path) -> None:
+ """A refusal gate's false positives are worse than the bug it forecloses (#414):
+ this one would refuse an ordinary isolated project whose `repo_root` merely spells
+ the same directory a different way. `load_paths` resolves both sides, but nothing
+ obliges a hand-built ProjectPaths — or a future caller — to have done so."""
+ (tmp_path / "p").mkdir()
+ paths = ProjectPaths(
+ project=tmp_path / "p",
+ implementation_artifacts=tmp_path / "p" / "impl",
+ planning_artifacts=tmp_path / "p" / "plan",
+ repo_root=tmp_path / "p" / ".." / "p",
+ )
+ assert paths.repo_root != paths.project, "the two spellings really are different"
+ assert bmadconfig.worktree_isolation_conflict(paths, "worktree") is None
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 59cdf33a..e8531fe6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,6 +3,7 @@
import argparse
import io
import json
+import shutil
import sys
import pytest
@@ -18,6 +19,7 @@
from bmad_loop import cli
from bmad_loop import policy as policy_mod
+from bmad_loop import verify
from bmad_loop.adapters import multiplexer as mux_mod
STORIES_SPEC_FOLDER = "_bmad-output/epic-1"
@@ -47,6 +49,18 @@ def _setup_stories_fixture(paths, entries, *, with_spec_md=True):
model = "gpt-5-codex"
"""
+# A third CLI for triage only, with dev+review sharing the default. The triage adapter
+# must differ by *name* rather than by model: `skill_tree` is a property of the profile,
+# and only two distinct values ship (claude/opencode-http -> `.claude/skills`;
+# codex/gemini/copilot/antigravity -> `.agents/skills`), so a same-name triage adapter
+# would read the very tree dev already reads and could not tell the two apart.
+TRIAGE_SPLIT_POLICY = """\
+[adapter]
+name = "claude"
+[adapter.triage]
+name = "gemini"
+"""
+
def _write_policy(project, text=DUAL_CLIENT_POLICY) -> None:
bmad_loop_dir = project / ".bmad-loop"
@@ -1549,9 +1563,9 @@ def test_start_rejects_invalid_run_id(project, monkeypatch, capsys, command, bad
def test_run_aborts_when_base_skills_missing(project, monkeypatch, capsys):
- """The orchestrator depends on the non-bundled upstream skills (bmad-dev-auto
- + the review hunters); a run must fail loudly at preflight (not stall mid-run)
- when they are absent."""
+ """The orchestrator depends on the non-bundled upstream skills (the dev
+ primitive + the review hunters); a run must fail loudly at preflight (not stall
+ mid-run) when they are absent."""
from conftest import git
install_bmad_config(project)
@@ -1564,7 +1578,139 @@ def test_run_aborts_when_base_skills_missing(project, monkeypatch, capsys):
assert cli.main(["run", "--project", str(project.project)]) == 1
err = capsys.readouterr().err
- assert "bmad-dev-auto" in err
+ assert "bmad-build-auto" in err
+
+
+def test_run_aborts_when_only_the_dev_shim_is_installed(project, monkeypatch, capsys):
+ """The post-#2651 hazard: the shim IS a SKILL.md, so a run would happily
+ dispatch `/bmad-dev-auto` into an interactive migration gate and HALT having
+ written nothing. The preflight refuses it and names the rename instead."""
+ from conftest import git, install_base_skills, install_dev_shim
+
+ install_bmad_config(project)
+ install_base_skills(project)
+ for tree in (".claude/skills", ".agents/skills"):
+ shutil.rmtree(project.project / tree / "bmad-build-auto")
+ shutil.rmtree(project.project / tree / "bmad-dev-auto")
+ install_dev_shim(project.project, tree)
+ write_sprint(project, {"1-1-a": "ready-for-dev"})
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "setup")
+ monkeypatch.setattr(cli, "Engine", _StubEngine)
+ monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES})
+
+ assert cli.main(["run", "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert "bmad-build-auto" in err and "HALT" in err
+
+
+def _install_renderer_stub_project(
+ project, monkeypatch, *, script: bool, utils: bool, config: bool
+):
+ """A project whose resolved primitive is a #2601 renderer stub in every tree.
+ `script`/`utils`/`config` choose which of the three renderer prerequisites exist,
+ so each caller isolates one absence. Everything is laid down before the commit —
+ the preflight runs behind a clean-worktree gate.
+
+ The script it writes is the REAL upstream shape (a module-scope
+ `from config_utils import ...`), because the sibling's requirement is keyed on
+ that text; `utils` has no default so every caller must say which era it means."""
+ from conftest import (
+ RENDERER_SCRIPT_IMPORTING_SIBLING,
+ git,
+ install_base_skills,
+ install_build_auto_skill,
+ )
+
+ install_bmad_config(project)
+ install_base_skills(project)
+ for tree in (".claude/skills", ".agents/skills"):
+ shutil.rmtree(project.project / tree / "bmad-dev-auto")
+ install_build_auto_skill(project.project, tree, renderer_stub=True)
+ if script:
+ rendered = project.project / "_bmad" / "scripts" / "render_skill.py"
+ rendered.parent.mkdir(parents=True, exist_ok=True)
+ rendered.write_text(RENDERER_SCRIPT_IMPORTING_SIBLING, encoding="utf-8")
+ if utils:
+ helper = project.project / "_bmad" / "scripts" / "config_utils.py"
+ helper.parent.mkdir(parents=True, exist_ok=True)
+ helper.write_text("# helper\n", encoding="utf-8")
+ if config:
+ central = project.project / "_bmad" / "config.toml"
+ central.parent.mkdir(parents=True, exist_ok=True)
+ central.write_text("[core]\nx = 1\n", encoding="utf-8")
+ write_sprint(project, {"1-1-a": "ready-for-dev"})
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "setup")
+ monkeypatch.setattr(cli, "Engine", _StubEngine)
+ monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES})
+
+
+def test_run_aborts_when_the_renderer_script_is_missing(project, monkeypatch, capsys):
+ """A renderer stub with no `_bmad/scripts/render_skill.py` HALTs every single
+ unattended session with nothing written, so the preflight refuses the run rather
+ than burning the whole budget on guaranteed result-less Stops."""
+ _install_renderer_stub_project(project, monkeypatch, script=False, utils=False, config=True)
+
+ assert cli.main(["run", "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert "_bmad/scripts/render_skill.py" in err and "HALT" in err
+
+
+def test_run_aborts_when_the_renderer_config_utils_is_missing(project, monkeypatch, capsys):
+ """The script's own sibling. `render_skill.py` imports `config_utils` at module
+ scope off sys.path[0], above its try/except, so a half-present unit dies on a
+ bare ModuleNotFoundError — the same result-less Stop, with the structured
+ `HALT: ` line replaced by a raw traceback. `_require_base_skills` has no
+ severity filter, so the run is refused here exactly like the other two."""
+ _install_renderer_stub_project(project, monkeypatch, script=True, utils=False, config=True)
+
+ assert cli.main(["run", "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert "_bmad/scripts/config_utils.py" in err and "HALT" in err
+
+
+def test_run_aborts_when_the_renderer_central_config_is_missing(project, monkeypatch, capsys):
+ """One file further in: the script unit is whole but `_bmad/config.toml` — the
+ renderer's one required layer — is not, so `uv run` raises before composing and
+ exits `HALT:`. Same result-less Stop, same refusal."""
+ _install_renderer_stub_project(project, monkeypatch, script=True, utils=True, config=False)
+
+ assert cli.main(["run", "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert "_bmad/config.toml" in err and "HALT" in err
+
+
+def test_run_aborts_when_the_renderer_entry_document_is_missing(project, monkeypatch, capsys):
+ """One layer further in again: the project-global script unit and config are all
+ present, and the skill's OWN `workflow.md` — the document `render_skill.py`
+ composes from — is not. `render entry is missing` exits `HALT:` on every story,
+ so the run is refused for the same reason as the three above.
+
+ The re-commit matters: `run`'s preflight is behind a clean-worktree gate, so
+ deleting a tracked file without staging it would abort for the wrong reason and
+ the test would pass with the gate reverted."""
+ from bmad_loop.install import RENDERER_ENTRY_REL
+
+ _install_renderer_stub_project(project, monkeypatch, script=True, utils=True, config=True)
+ for tree in (".claude/skills", ".agents/skills"):
+ (project.project / tree / "bmad-build-auto" / RENDERER_ENTRY_REL).unlink()
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "truncated renderer install")
+
+ assert cli.main(["run", "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert RENDERER_ENTRY_REL in err and "HALT" in err
+
+
+def test_run_proceeds_once_the_renderer_files_are_present(project, monkeypatch, capsys):
+ """The clearing leg. Without it, ablating any of the three `is_file()` predicates
+ away leaves all three aborts above passing — the run would refuse a healthy stub
+ project and no test would notice."""
+ _install_renderer_stub_project(project, monkeypatch, script=True, utils=True, config=True)
+
+ assert cli.main(["run", "--project", str(project.project)]) == 0
+ assert "render_skill.py" not in capsys.readouterr().err
def _stub_run_tui(monkeypatch):
@@ -3380,14 +3526,18 @@ def test_validate_stories_folder_known_selector_ok(project):
CLAUDE_ONLY_POLICY = '[adapter]\nname = "claude"\nmodel = "opus"\n'
-def _make_validate_pass(project, monkeypatch, capsys):
+def _make_validate_pass(project, monkeypatch, capsys, *, policy_text=CLAUDE_ONLY_POLICY):
"""Set a project up so every validate gate passes, and pin the two gates whose
outcome is a property of the *host* rather than of the project: whether the CLI
binary is on PATH and whether a multiplexer is installed. Without those pins the
rc-0 leg would pass or fail by machine, which is exactly the kind of green that
- means nothing."""
+ means nothing.
+
+ ``policy_text`` is keyword-only with the single-CLI default, so every existing
+ caller is unchanged. It is written *before* the `init` below, because `cmd_init`
+ derives the CLI list it installs skills and hooks into from the policy."""
install_bmad_config(project)
- _write_policy(project.project, CLAUDE_ONLY_POLICY)
+ _write_policy(project.project, policy_text)
write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
install_dev_base_skills(project.project, folder_id=True)
assert cli.main(["init", "--project", str(project.project)]) == 0 # registers the hooks
@@ -3402,6 +3552,210 @@ def _make_validate_pass(project, monkeypatch, capsys):
capsys.readouterr() # drop `init`'s chatter — the next read must see only the document
+def _findings_by_check(doc):
+ return {f["check"]: f for f in doc["findings"]}
+
+
+def _render_findings(doc) -> str:
+ """Draw a document through the TUI renderer (#210) and return the text. The
+ `{'` assertion is that file's nested-dict tell: a renderer that str()s a detail
+ shape it did not model prints a Python repr."""
+ from rich.console import Console
+
+ from bmad_loop.tui import widgets
+
+ console = Console(width=96)
+ with console.capture() as capture:
+ console.print(widgets.validate_findings(doc, details=True))
+ rendered = capture.get()
+ assert "{'" not in rendered
+ return rendered
+
+
+def test_validate_names_the_resolved_dev_primitive(project, capsys, monkeypatch):
+ """`skills.base` reports which era actually resolved — on an upgraded project
+ that ok line is the operator's confirmation the rename was picked up, so it
+ must not be a hardcoded name."""
+ from conftest import install_build_auto_skill
+
+ _make_validate_pass(project, monkeypatch, capsys) # lays down the LEGACY skill
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ base = _findings_by_check(doc)["skills.base"]
+ assert "bmad-dev-auto" in base["message"]
+ assert base["detail"]["dev_primitive"] == ["bmad-dev-auto"]
+
+ install_build_auto_skill(project.project, ".claude/skills")
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ # rc 1 only because laying down a skill dirtied the worktree — the skills gate
+ # itself still passes, now naming the new primitive
+ base = _findings_by_check(doc)["skills.base"]
+ assert base["severity"] == "ok"
+ assert "bmad-build-auto" in base["message"] and "bmad-dev-auto" not in base["message"]
+ assert base["detail"]["dev_primitive"] == ["bmad-build-auto"]
+
+
+def test_validate_skills_gates_ignore_a_triage_only_tree(project, capsys, monkeypatch):
+ """#405: every `skills.*` check asks a dev-primitive question — which primitive
+ resolves, whether the review hunters it invokes inline are there, whether a
+ renderer stub's script unit is whole. `cmd_validate` built its tree list from all
+ three adapter roles, so a third CLI used only for triage had the whole bmm module
+ demanded of a tree whose only prompt (`/bmad-loop-sweep`) ships in this wheel.
+
+ The triage tree here carries exactly what the un-narrowed gate choked on: a
+ RESOLVED renderer stub — resolution is what arms the renderer checks, so a merely
+ absent tree could not reproduce this — with no `_bmad/scripts/` and no
+ `_bmad/config.toml`, plus a legacy customization override the rename orphaned."""
+ from conftest import install_build_auto_skill
+
+ _make_validate_pass(project, monkeypatch, capsys, policy_text=TRIAGE_SPLIT_POLICY)
+ install_build_auto_skill(project.project, ".agents/skills", renderer_stub=True)
+ custom = project.project / "_bmad" / "custom"
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text('[core]\nx = "y"\n', encoding="utf-8")
+ # `_make_validate_pass` commits internally, and validate gates on a clean worktree.
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "triage-only tree")
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ assert doc["ok"] is True
+ checks = _findings_by_check(doc)
+ for absent in (
+ "skills.dev-renderer",
+ "skills.dev-renderer-config",
+ "skills.base-missing",
+ "skills.customize-legacy",
+ ):
+ assert absent not in checks, f"{absent} fired on a tree no dev session reads"
+ assert checks["skills.base"]["detail"]["trees"] == [".claude/skills"]
+ assert checks["skills.base"]["detail"]["dev_primitive"] == ["bmad-dev-auto"]
+ # The triage tree is live, not absent: `init` laid this wheel's own sweep skill
+ # into it, which is precisely why it needs nothing from the bmm module.
+ assert (project.project / ".agents/skills/bmad-loop-sweep/SKILL.md").is_file()
+
+
+def test_validate_stories_dispatch_ignores_a_triage_only_tree(project, capsys):
+ """The same narrowing on the stories-mode leg, which `cmd_validate` fed from its
+ own all-role list. The triage tree's primitive has step-01 present but without the
+ dispatch marker, so an un-narrowed probe reports `-stale` — a finding only a tree
+ that actually resolved can produce."""
+ from conftest import install_build_auto_skill
+
+ install_bmad_config(project)
+ _setup_stories_fixture(project, [_stories_entry("1")])
+ _write_policy(project.project, TRIAGE_SPLIT_POLICY + STORIES_POLICY)
+ install_dev_base_skills(project.project, ".claude/skills", folder_id=True)
+ install_build_auto_skill(project.project, ".agents/skills", folder_id=False)
+
+ # rc 1 (the fixture leaves the worktree dirty); `--json` never calls report.render,
+ # so stderr stays empty and the document is still owed in full.
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ checks = _findings_by_check(doc)
+ assert "skills.stories-dispatch-stale" not in checks
+ assert checks["skills.stories-dispatch"]["detail"]["trees"] == [".claude/skills"]
+
+
+def test_validate_skills_ok_line_needs_a_dev_tree_not_just_a_profile(project, capsys):
+ """The `skills.base` ok line gates on the dev+review trees, not on `profiles`.
+ Policy never validates an adapter *name* — the first test is `get_profile` — so an
+ unknown dev CLI beside a loadable triage one leaves `profiles` non-empty while
+ nothing dev-side resolved. Gating on `profiles` there printed "upstream skills
+ present ( + review hunters)": a green sentence assembled from an empty probe.
+
+ The triage tree is deliberately given a COMPLETE base install. Leave it empty and
+ the ok line is suppressed by `base_problems` instead — the gate is never reached,
+ and the test passes with the gate reverted."""
+ install_bmad_config(project)
+ install_dev_base_skills(project.project, ".agents/skills", folder_id=False)
+ _write_policy(
+ project.project, '[adapter]\nname = "nosuchcli"\n[adapter.triage]\nname = "gemini"\n'
+ )
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ checks = _findings_by_check(doc)
+ assert checks["adapter.profile"]["severity"] == "problem"
+ assert "nosuchcli" in checks["adapter.profile"]["message"]
+ # Triage's profile DID load, so `profiles` is non-empty — the old gate's input.
+ assert checks["adapter.binary"]["detail"]["binary"] == "gemini"
+ # ...yet nothing resolved to probe, so the line must not be emitted at all.
+ assert "skills.base" not in checks
+
+
+def test_validate_reports_the_shim_as_a_problem(project, capsys, monkeypatch):
+ from conftest import install_dev_shim
+
+ _make_validate_pass(project, monkeypatch, capsys)
+ for marker in ("step-04-review.md", "customize.toml"):
+ (project.project / ".claude/skills/bmad-dev-auto" / marker).unlink()
+ install_dev_shim(project.project, ".claude/skills") # rewrite SKILL.md as the shim
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ shim = _findings_by_check(doc)["skills.base-shim"]
+ assert shim["severity"] == "problem"
+ assert "bmad-build-auto" in shim["message"]
+ assert "skills.base" not in _findings_by_check(doc) # no ok line beside the failure
+ assert "skills.base-shim" in _render_findings(doc) # new detail shape draws
+
+
+def test_validate_fails_on_a_renderer_stub_and_warns_on_an_orphaned_customize_file(
+ project, capsys, monkeypatch
+):
+ """The two renderer findings are preflight problems and DO flip the verdict —
+ each is a deterministic HALT-without-a-spec on every story. The customize
+ orphan beside them is advisory and would not have.
+
+ This is also the only end-to-end proof that each id clears
+ `ValidationReport.add`'s registry assert — `_make_validate_pass` installs no
+ renderer stub, so the every-id-registered test never reaches these sites."""
+ from conftest import install_build_auto_skill
+
+ _make_validate_pass(project, monkeypatch, capsys) # claude-only policy
+ shutil.rmtree(project.project / ".claude/skills/bmad-dev-auto")
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=True)
+ custom = project.project / "_bmad" / "custom"
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8")
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "upgrade to bmad-build-auto")
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ assert doc["ok"] is False # the renderer problems fail the run
+ found = _findings_by_check(doc)
+ assert found["skills.dev-renderer"]["severity"] == "problem"
+ assert found["skills.dev-renderer-config"]["severity"] == "problem" # no _bmad/config.toml
+ assert found["skills.customize-legacy"]["severity"] == "warning"
+ assert "_bmad/scripts/render_skill.py" in found["skills.dev-renderer"]["message"]
+ rendered = _render_findings(doc) # every new detail shape draws, plus skills.base's
+ assert "skills.dev-renderer" in rendered and "skills.customize-legacy" in rendered
+ assert "skills.dev-renderer-config" in rendered
+
+
+def test_validate_stays_ok_when_only_the_customize_orphan_fires(project, capsys, monkeypatch):
+ """The advisory half, isolated: with the renderer's script and central config
+ both present, the orphaned override is the only finding and the verdict stays
+ ok. Without this leg the split above cannot show that `skills.customize-legacy`
+ is still a warning rather than riding the renderer problems' rc."""
+ from conftest import install_build_auto_skill
+
+ _make_validate_pass(project, monkeypatch, capsys) # claude-only policy
+ shutil.rmtree(project.project / ".claude/skills/bmad-dev-auto")
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=True)
+ script = project.project / "_bmad" / "scripts" / "render_skill.py"
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# renderer\n", encoding="utf-8")
+ (project.project / "_bmad" / "config.toml").write_text("[core]\nx = 1\n", encoding="utf-8")
+ custom = project.project / "_bmad" / "custom"
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8")
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "upgrade to bmad-build-auto")
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ assert doc["ok"] is True # a warning does not fail the run
+ found = _findings_by_check(doc)
+ assert "skills.dev-renderer" not in found and "skills.dev-renderer-config" not in found
+ assert found["skills.customize-legacy"]["severity"] == "warning"
+
+
def test_validate_json_clean_project_is_a_pure_document_at_rc_0(project, capsys, monkeypatch):
"""The happy path: one whole document on stdout, nothing else, ok true."""
_make_validate_pass(project, monkeypatch, capsys)
@@ -3839,3 +4193,507 @@ def test_platform_preflight_notes_forced_selection_provenance(mux_registry, monk
mux_registry.get_multiplexer.cache_clear()
notes, problems = _preflight_notes_problems()
assert any("forced by BMAD_LOOP_MUX_BACKEND" in n for n in notes)
+
+
+def test_dry_run_previews_the_disk_resolved_primitive_per_role(project, capsys):
+ """#405: the preview must spell what `run` would actually dispatch, resolved
+ per role from that adapter's skill tree. dev=claude reads .claude/skills
+ (post-rename here) and review=codex reads .agents/skills (still pre-rename),
+ so one honest preview shows both eras side by side. The all-legacy default is
+ pinned by test_dry_run_renders_per_stage_commands, which installs no skills."""
+ from conftest import install_build_auto_skill, install_dev_base_skills
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ install_build_auto_skill(project.project, ".claude/skills")
+ install_dev_base_skills(project.project, ".agents/skills", folder_id=False)
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args) == 0
+ out = capsys.readouterr().out
+ dev_line = next(line for line in out.splitlines() if "dev:" in line)
+ review_line = next(line for line in out.splitlines() if "review:" in line)
+ assert "/bmad-build-auto 1-1-a" in dev_line
+ # codex's prompt_template rewrites "/skill args" into "$skill ... args"
+ assert "$bmad-dev-auto" in review_line and "" in review_line
+ assert "bmad-build-auto" not in review_line
+
+
+def test_dry_run_stories_previews_the_resolved_primitive(project, capsys):
+ """The folder+id dispatch preview follows the same resolution."""
+ from conftest import install_build_auto_skill
+
+ _setup_stories_fixture(project, [_stories_entry("1")])
+ install_build_auto_skill(project.project, ".claude/skills")
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0
+ out = capsys.readouterr().out
+ assert "/bmad-build-auto Spec folder: _bmad-output/epic-1. Story id: 1." in out
+ assert "/bmad-dev-auto Spec folder:" not in out
+
+
+def test_skill_trees_skips_the_triage_only_tree(project):
+ """#405: the trees the skills preflight probes are the dev+review ones. Triage's
+ entire prompt surface is `/bmad-loop-sweep`, which this wheel bundles and
+ `bmad-loop init` lays into that tree, so a triage-only CLI never needs one byte of
+ the bmm module — and the set gated here has to stay the set
+ `Engine._worktree_profiles` provisions."""
+ _write_policy(project.project, TRIAGE_SPLIT_POLICY)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+
+ assert cli._skill_trees(project.project, pol) == [".claude/skills"]
+
+
+def test_skill_trees_still_covers_a_split_dev_review_pair(project):
+ """Control for the narrowing: a dev/review split across two CLIs still yields
+ BOTH trees. Kills any "fix" that collapses to the dev adapter's tree alone —
+ the review session dispatches the very same primitive."""
+ _write_policy(project.project, DUAL_CLIENT_POLICY) # dev=claude, review=codex
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+
+ assert cli._skill_trees(project.project, pol) == [".claude/skills", ".agents/skills"]
+
+
+def _triage_tree_renderer_stub(paths, stub_tree: str) -> None:
+ """A healthy INLINE dev primitive plus its review hunters in `.claude/skills`
+ (what dev+review actually dispatch), and a resolved #2601 renderer stub in
+ ``stub_tree`` with no `_bmad/` beside it to satisfy it.
+
+ `stub_tree` is the whole experiment: `.agents/skills` puts the broken stub where
+ only the triage adapter of TRIAGE_SPLIT_POLICY reads, `.claude/skills` puts the
+ identical bytes in the dev tree."""
+ from conftest import install_build_auto_skill
+
+ install_dev_base_skills(paths.project, ".claude/skills", folder_id=False)
+ install_build_auto_skill(paths.project, stub_tree, renderer_stub=True)
+
+
+def test_require_base_skills_ignores_a_broken_triage_only_tree(project, capsys):
+ """A renderer stub in a tree only triage reads must not abort run/sweep/resume.
+ Before this, `[adapter.triage] name = "gemini"` under a claude dev/review pair was
+ a hard preflight FAIL — the renderer checks are problems, not warnings, so a
+ config 0.9.0 merely nagged about became unrunnable on 0.9.1."""
+ _triage_tree_renderer_stub(project, ".agents/skills")
+ _write_policy(project.project, TRIAGE_SPLIT_POLICY)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+
+ assert cli._require_base_skills(project.project, pol) is True
+ assert capsys.readouterr().err == "" # a passing gate is silent
+
+
+def test_require_base_skills_still_fails_on_a_dev_tree_renderer_stub(project, capsys):
+ """Control: the identical stub in the DEV tree still refuses the run. Without it,
+ deleting the renderer checks outright would pass the test above."""
+ _triage_tree_renderer_stub(project, ".claude/skills")
+ _write_policy(project.project, TRIAGE_SPLIT_POLICY)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+
+ assert cli._require_base_skills(project.project, pol) is False
+ err = capsys.readouterr().err
+ assert "_bmad/scripts/render_skill.py" in err and "_bmad/config.toml" in err
+
+
+def _shim_only(paths) -> None:
+ """Post-rename install left with nothing but the forwarding shim, in every
+ tree the dual-client policy reads."""
+ from conftest import install_base_skills, install_dev_shim
+
+ install_base_skills(paths)
+ for tree in (".claude/skills", ".agents/skills"):
+ shutil.rmtree(paths.project / tree / "bmad-build-auto")
+ shutil.rmtree(paths.project / tree / "bmad-dev-auto")
+ install_dev_shim(paths.project, tree)
+
+
+def test_dry_run_warns_when_preflight_would_abort(project, capsys):
+ """#405: `--dry-run` returns before `_require_base_skills`, so a broken install
+ still renders a plausible preview. On a shim-only project that preview is a lie
+ the operator cannot see through — the shim IS a valid slash command, so
+ `/bmad-dev-auto ...` reads fine and would HALT the session. Say so.
+
+ stdout keeps the schedule (a diagnostic must not withhold what was asked for)
+ and the exit code stays 0; the banner is stderr-only."""
+ _shim_only(project)
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args) == 0
+ out, err = capsys.readouterr()
+ assert "NOT runnable" in err and "bmad-build-auto" in err
+ assert "run `bmad-loop validate` for details" in err
+ assert "1-1-a" in out # the schedule itself still rendered
+
+
+def test_dry_run_stories_warns_when_preflight_would_abort(project, capsys):
+ """Same banner on the stories preview, which additionally probes folder+id
+ dispatch support on the resolved primitive."""
+ _shim_only(project)
+ _setup_stories_fixture(project, [_stories_entry("1")])
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0
+ out, err = capsys.readouterr()
+ assert "NOT runnable" in err and "bmad-build-auto" in err
+ assert "Story id: 1." in out
+
+
+def test_sweep_dry_run_warns_when_preflight_would_abort(project, capsys):
+ """`cmd_sweep` has the same shape — dry-run returns before its preflight."""
+ _shim_only(project)
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+
+ assert cli._sweep_dry_run(project, pol) == 0
+ assert "NOT runnable" in capsys.readouterr().err
+
+
+def test_dry_run_warns_when_the_renderer_files_are_missing(project, capsys):
+ """The banner mirrors `_require_base_skills` by reading the same list, so
+ promoting the renderer checks to problems covered the preview too. Without this
+ the preview would print a `/bmad-build-auto` schedule for a project whose every
+ session HALTs — the exact lie the banner exists to stop."""
+ from conftest import install_base_skills, install_build_auto_skill
+
+ install_base_skills(project)
+ for tree in (".claude/skills", ".agents/skills"):
+ shutil.rmtree(project.project / tree / "bmad-dev-auto")
+ install_build_auto_skill(project.project, tree, renderer_stub=True)
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args) == 0
+ out, err = capsys.readouterr()
+ assert "NOT runnable" in err
+ assert "_bmad/scripts/render_skill.py" in err and "_bmad/config.toml" in err
+ assert "1-1-a" in out # the schedule itself still rendered
+
+
+def test_dry_run_is_silent_when_preflight_would_pass(project, capsys):
+ """The banner must be evidence, not decoration: a complete install prints
+ nothing to stderr. Without this the warning could be unconditional and every
+ assertion above would still pass."""
+ from conftest import install_base_skills
+
+ install_base_skills(project)
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ _write_policy(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ args = argparse.Namespace(epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(project, pol, args) == 0
+ out, err = capsys.readouterr()
+ assert err == ""
+ assert "/bmad-build-auto 1-1-a" in out
+
+
+# ---- #414: worktree isolation is refused under a repo_root override ----------
+
+ISOLATION_WORKTREE_POLICY = (
+ '[adapter]\nname = "claude"\nmodel = "opus"\n\n[scm]\nisolation = "worktree"\n'
+)
+NO_ISOLATION_POLICY = '[adapter]\nname = "claude"\nmodel = "opus"\n\n[scm]\nisolation = "none"\n'
+REFUSAL = 'isolation = "worktree" is not supported'
+
+
+def _override_repo_root(paths, rel="git-root"):
+ """Point `repo_root` away from the project — #414's monorepo layout, minus the
+ monorepo. The target has no `_bmad/`, which is the shape the issue reports, but
+ it DOES exist: `cmd_run`/`cmd_sweep` probe `verify.worktree_clean(repo_root)`,
+ and against a missing dir that raises `GitError` instead of answering, which
+ would make every "the isolation gate spoke first" assertion below unfalsifiable
+ — the later gate would crash rather than print the message it is asserted not to
+ print."""
+ (paths.project / rel).mkdir(exist_ok=True)
+ cfg = paths.project / "_bmad" / "bmm" / "config.yaml"
+ cfg.write_text(cfg.read_text() + f"repo_root: '{{project-root}}/{rel}'\n", encoding="utf-8")
+
+
+def test_validate_refuses_worktree_isolation_under_a_repo_root_override(
+ project, monkeypatch, capsys
+):
+ """#414: provisioning seeds every non-git surface from `repo_root` while every
+ gate validate runs probes `project`, so a split pair makes validate approve a
+ surface the isolated run never receives. A `problem`, so the rc flips — and the
+ fixture is committed first, so the rc-1 is this gate and not a dirty tree."""
+ _make_validate_pass(project, monkeypatch, capsys, policy_text=ISOLATION_WORKTREE_POLICY)
+ _override_repo_root(project)
+ git(project.project, "commit", "-qam", "repo_root override")
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ finding = _findings_by_check(doc)["policy.isolation-repo-root"]
+ assert finding["severity"] == "problem"
+ # Both remediations named, and only remediations that exist on this line: fix-1
+ # (plumbing `project` through provisioning) is a main-line option, so the message
+ # must not gesture at a flag or a version that would make the pair work.
+ assert "Remove the `repo_root` key" in finding["message"]
+ assert '`isolation = "none"`' in finding["message"]
+ assert finding["detail"] == {
+ "repo_root": str(project.project / "git-root"),
+ "project": str(project.project),
+ }
+ _render_findings(doc) # the detail shape draws in the TUI modal
+
+
+@pytest.mark.parametrize(
+ "override,policy_text",
+ [(True, NO_ISOLATION_POLICY), (False, ISOLATION_WORKTREE_POLICY)],
+ ids=["override-without-worktree", "worktree-without-override"],
+)
+def test_validate_isolation_gate_needs_both_halves(
+ project, monkeypatch, capsys, override, policy_text
+):
+ """Either half alone is a configuration this release supports and documents: a
+ `repo_root` override under `isolation = "none"` is the monorepo knob (README:446),
+ and worktree isolation without an override is the ordinary isolated setup. The
+ gate stays silent on both, and validate still passes — the green rc is the second
+ witness, since a gate that fired would take the whole verdict with it."""
+ from bmad_loop import bmadconfig
+
+ _make_validate_pass(project, monkeypatch, capsys, policy_text=policy_text)
+ if override:
+ _override_repo_root(project)
+ git(project.project, "commit", "-qam", "repo_root override")
+
+ # The fixture can express the failing value: each leg really does carry exactly
+ # one half of it, so neither silence is the silence of a setup that never landed.
+ loaded = bmadconfig.load_paths(project.project)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ assert (loaded.repo_root != loaded.project) is override
+ assert (pol.scm.isolation == "worktree") is not override
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ assert "policy.isolation-repo-root" not in _findings_by_check(doc)
+
+
+def _split_root_project(project, *, policy_text=ISOLATION_WORKTREE_POLICY):
+ install_bmad_config(project)
+ _override_repo_root(project)
+ _write_policy(project.project, policy_text)
+ write_sprint(project, {"1-1-a": "ready-for-dev"})
+ # `worktree_clean` scopes `git status` to `-- .` inside the dir it is handed, so
+ # only dirt UNDER repo_root can make the next gate speak. Without this the
+ # ordering assertion below passes no matter where the gate sits.
+ (project.project / "git-root" / "dirty.txt").write_text("uncommitted\n", encoding="utf-8")
+
+
+@pytest.mark.parametrize("command", ["run", "sweep"])
+def test_start_refuses_worktree_isolation_under_a_repo_root_override(
+ project, monkeypatch, capsys, command
+):
+ """The refusal validate reports is also the one the real command makes, and it is
+ the FIRST one: `repo_root` is left genuinely dirty, so a gate ordered after
+ `worktree_clean` would answer "commit or stash first" instead — a message that
+ sends the operator to fix something that is not the problem. Both halves of that
+ are load-bearing: the probe reads `repo_root`, not `project`, so dirtying the
+ project would prove nothing."""
+ _split_root_project(project)
+ monkeypatch.setattr(cli, "Engine", _StubEngine)
+ monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES})
+
+ assert cli.main([command, "--project", str(project.project)]) == 1
+ err = capsys.readouterr().err
+ assert REFUSAL in err
+ assert "not clean" not in err
+
+
+def test_resume_refuses_worktree_isolation_under_a_repo_root_override(project, monkeypatch, capsys):
+ """Resume re-reads config.yaml and policy.toml off disk, so it is a second
+ entrypoint into the same provisioning: a run whose config grew the override
+ mid-flight must not finish its remaining stories through worktrees the preflight
+ would now refuse. Refused before the `run-resume` entry, so the journal does not
+ record a resume that never happened."""
+ run_dir = _paused_run_for_resume(project, monkeypatch)
+ _write_policy(project.project, RESUME_POLICY + '\n[scm]\nisolation = "worktree"\n')
+ _override_repo_root(project)
+ monkeypatch.setattr(cli, "Engine", lambda **kw: pytest.fail("engine constructed"))
+
+ assert cli._resume_paused_run(project.project, run_dir) == 1
+ assert REFUSAL in capsys.readouterr().err
+ assert _resume_entries(run_dir) == []
+
+
+def test_auto_sweep_refuses_worktree_isolation_under_a_repo_root_override(project, monkeypatch):
+ """The child sweep an engine auto-triggers is the one caller that reloads
+ policy.toml while reusing the parent's already-loaded paths — so it is the only
+ way a mid-run flip to `isolation = "worktree"` reaches provisioning under a split
+ the parent's own start was allowed not to check.
+
+ It RAISES rather than returning, which is the whole point: `_maybe_auto_sweep`
+ has already journaled `sweep-auto-trigger` and latched the trigger by the time it
+ calls the factory, and it reads a plain return as success — so a quiet decline is
+ recorded as `sweep-auto-finished`, a child sweep that ran and finished when none
+ was launched. Raising lands on the `sweep-auto-failed` + notify path instead,
+ which is the same one an unparseable policy.toml already takes, and the parent
+ run is still unaffected (`_maybe_auto_sweep` swallows it)."""
+ from bmad_loop import bmadconfig
+
+ _split_root_project(project)
+ started = []
+ monkeypatch.setattr(cli, "_start_sweep", lambda *a, **kw: started.append(kw) or 0)
+
+ factory = cli._sweep_factory(project.project, bmadconfig.load_paths(project.project))
+ with pytest.raises(RuntimeError, match=REFUSAL):
+ factory("epic-boundary")
+ assert started == []
+
+
+def test_dry_run_banner_names_the_isolation_refusal_first(project, capsys):
+ """The preview keeps rc 0 and still renders the schedule, but the banner has to
+ name every refusal the dry-run's early return skips past — and in the order the
+ real command makes them. (Not every refusal there is: the dirty-tree, queue and
+ run-id gates are not part of this banner.) This
+ project is short of base skills too, so the ordering is observable: the isolation
+ refusal aborts before `_require_base_skills`, so it heads the list."""
+ import dataclasses
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ _write_policy(project.project, ISOLATION_WORKTREE_POLICY)
+ pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml")
+ paths = dataclasses.replace(project, repo_root=project.project / "git-root")
+ args = argparse.Namespace(epic=None, story=None, max_stories=None)
+
+ assert cli._dry_run(paths, pol, args) == 0
+ out, err = capsys.readouterr()
+ fails = [line for line in err.splitlines() if line.startswith(" FAIL:")]
+ assert REFUSAL in fails[0]
+ assert len(fails) > 1, "the base-skill problems the banner already reported"
+ assert "1-1-a" in out # the schedule itself still rendered
+
+
+def test_validate_reports_an_undecodable_policy_instead_of_crashing(project, capsys):
+ """`read_text` raises `UnicodeDecodeError` on a policy.toml saved as UTF-16 or
+ latin-1 — a ValueError, not an OSError, so it used to escape every
+ `except (PolicyError, OSError)` in the codebase, including `_configure_mux`,
+ which runs before argument dispatch on EVERY command. The result was a bare
+ traceback at startup in place of the named findings validate exists to print.
+ `policy.load` converts it, so the file is reported like any other bad policy."""
+ _write_policy(project.project, "")
+ (project.project / ".bmad-loop" / "policy.toml").write_bytes(b'[scm]\nisolation = "\xff\xfe"\n')
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ finding = _findings_by_check(doc)["policy"]
+ assert finding["severity"] == "problem"
+ assert "not valid UTF-8" in finding["message"]
+
+
+def test_validate_reports_an_undecodable_bmad_config_instead_of_crashing(project, capsys):
+ """The `config.yaml` half of the same conversion. Separate from the policy leg
+ because they are separate loaders with separate typed errors, and the TUI guard
+ that first exposed this calls BOTH — a fix to one would leave the other raising a
+ raw ValueError straight through the handler."""
+ _write_policy(project.project, CLAUDE_ONLY_POLICY)
+ cfg = project.project / "_bmad" / "bmm"
+ cfg.mkdir(parents=True, exist_ok=True)
+ (cfg / "config.yaml").write_bytes(b"implementation_artifacts: '\xff\xfe'\n")
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys, rc=1)
+ finding = _findings_by_check(doc)["bmad-config"]
+ assert finding["severity"] == "problem"
+ assert "not valid UTF-8" in finding["message"]
+
+
+# ---- #409: a tracked _bmad/render/ keeps churning through both shields -------
+
+
+def _committed_render_snapshot(project, *, track: bool):
+ """A renderer output tree at the depth and location `render_skill.py` publishes
+ to (the two hash segments are stand-ins, not real digests). `track` forces it past
+ the `_bmad/render/` line `init` just wrote into .gitignore — which is the state
+ #409 is about: an ignore rule does not untrack anything."""
+ snapshot = project.project / "_bmad" / "render" / "bmad-build-auto" / "sk-a1b2c3" / "d4e5f6"
+ snapshot.mkdir(parents=True)
+ (snapshot / "SKILL.md").write_text("# rendered\n", encoding="utf-8")
+ if track:
+ git(project.project, "add", "-f", "_bmad/render")
+ git(project.project, "commit", "-q", "-m", "committed render output")
+ return snapshot
+
+
+def test_validate_warns_when_rendered_output_is_tracked(project, monkeypatch, capsys):
+ """#409: a tracked path ignores .gitignore and info/exclude entirely, so both
+ shields 0.9.1 added miss the projects that already committed the renderer's
+ output — and those keep gaining a snapshot dir per machine, per checkout path
+ and per renderer bump. A warning, not a problem: nothing here stops a session,
+ so `ok` and the exit code both stay green."""
+ _make_validate_pass(project, monkeypatch, capsys)
+ _committed_render_snapshot(project, track=True)
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ finding = _findings_by_check(doc)["git.render-tracked"]
+ assert finding["severity"] == "warning"
+ assert "git rm -r --cached _bmad/render" in finding["message"]
+ assert doc["ok"] is True and doc["counts"]["problem"] == 0
+ _render_findings(doc) # the detail shape draws in the TUI modal
+
+
+def test_validate_render_tracked_is_silent_when_the_output_is_only_on_disk(
+ project, monkeypatch, capsys
+):
+ """The control that pins WHICH state the probe reads. Every project that has run
+ a renderer has a `_bmad/render/` full of files; only the index distinguishes the
+ one that needs the warning. Same tree as above, never added — so a probe that
+ stat'ed the filesystem would fire here, on essentially every install."""
+ _make_validate_pass(project, monkeypatch, capsys)
+ snapshot = _committed_render_snapshot(project, track=False)
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ assert (snapshot / "SKILL.md").is_file(), "the tree the probe declined to warn about"
+ assert "git.render-tracked" not in _findings_by_check(doc)
+
+
+def test_validate_render_tracked_ignores_rc_zero_git_chatter(project, monkeypatch, capsys):
+ """`git ls-files` exits 0 while still writing to stderr — here a `core.fsmonitor`
+ hook that cannot exec, the Windows-first perf knob whose usual failure this is.
+ Read off a stdout+stderr merge that chatter is indistinguishable from an index
+ entry, so the warning fires at a project that tracks nothing and sends the
+ operator to `git rm -r --cached` a path git has never heard of. Nothing is added
+ here, so the only thing that can make the probe answer True is the noise.
+
+ `worktree_clean` reads the same stream off the same repo, so the green rc is the
+ second witness: it is the sibling that turns the identical chatter into "git
+ worktree is not clean" for a checkout with nothing in it."""
+ _make_validate_pass(project, monkeypatch, capsys)
+ _committed_render_snapshot(project, track=False)
+ git(project.project, "config", "core.fsmonitor", ".git/hooks/absent-fsmonitor-hook")
+ assert verify.path_tracked(project.project, "_bmad/render") is False
+ assert verify.worktree_clean(project.project) is True
+
+ doc = machine_json(["validate", "--project", str(project.project), "--json"], capsys)
+ findings = _findings_by_check(doc)
+ assert "git.render-tracked" not in findings
+ assert findings["git.worktree-clean"]["severity"] == "ok"
+
+
+def test_validate_render_tracked_degrades_quietly_outside_a_git_repo(tmp_path, capsys):
+ """No repo means no index, and a check that could not ask its question must not
+ answer it — neither a warning nor a fabricated `ok`.
+
+ It also records what #409's own premise gets wrong: validate does NOT pass
+ outside a repo on this line. `worktree_clean` raises first and `git.probe` fails
+ the run, which is asserted here so the silence above cannot be mistaken for
+ "validate is fine outside a repo". Skipping is still right — a second finding
+ saying the same thing helps nobody."""
+ cfg = tmp_path / "_bmad" / "bmm"
+ cfg.mkdir(parents=True)
+ (cfg / "config.yaml").write_text(
+ "implementation_artifacts: '{project-root}/impl'\n"
+ "planning_artifacts: '{project-root}/plan'\n",
+ encoding="utf-8",
+ )
+ (tmp_path / "_bmad" / "render").mkdir()
+ _write_policy(tmp_path, CLAUDE_ONLY_POLICY)
+
+ doc = machine_json(["validate", "--project", str(tmp_path), "--json"], capsys, rc=1)
+ findings = _findings_by_check(doc)
+ assert "git.render-tracked" not in findings
+ assert findings["git.probe"]["severity"] == "problem"
diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py
index ffa89906..83cee672 100644
--- a/tests/test_deferredwork.py
+++ b/tests/test_deferredwork.py
@@ -9,6 +9,7 @@
field_severity,
has_legacy,
mark_done,
+ mark_open,
next_seq,
open_ids,
parse_ledger,
@@ -113,6 +114,104 @@ def test_mark_done_missing_entry(tmp_path):
assert path.read_text(encoding="utf-8") == snapshot
+def test_mark_open_round_trips_mark_done_byte_for_byte(tmp_path):
+ """The contract that lets a caller undo a close without having snapshotted the
+ file: `mark_done` writes its resolution on the line directly below the status,
+ and `mark_open` removes exactly that line."""
+ path = write_ledger(tmp_path)
+ before = path.read_text(encoding="utf-8")
+ assert mark_done(path, "DW-1", "2026-06-11", "resolved by sweep bundle dw-fix")
+ assert path.read_text(encoding="utf-8") != before
+ assert mark_open(path, "DW-1", "resolved by sweep bundle dw-fix")
+ assert path.read_text(encoding="utf-8") == before
+
+
+def test_mark_open_touches_only_target(tmp_path):
+ path = write_ledger(tmp_path)
+ assert mark_done(path, "DW-1", "2026-06-11", "by dw-a")
+ assert mark_done(path, "DW-3", "2026-06-11", "by dw-a")
+ assert mark_open(path, "DW-1", "by dw-a")
+ entries = {e.id: e for e in parse_ledger(path.read_text(encoding="utf-8"))}
+ assert entries["DW-1"].open and "resolution:" not in entries["DW-1"].body
+ assert entries["DW-3"].status == "done 2026-06-11"
+ assert entries["DW-2"].status == "done 2026-05-25" # untouched pre-existing close
+
+
+def test_mark_open_refuses_a_close_it_did_not_write(tmp_path):
+ """The guard that makes this an UNDO rather than a reopen. DW-2 is closed in the
+ fixture with no resolution line at all, and a close carrying someone else's note
+ is equally off limits — neither is the caller's to revoke, and once reopened the
+ ledger has no field that could record who did it or why."""
+ path = write_ledger(tmp_path)
+ assert mark_done(path, "DW-1", "2026-06-11", "resolved by sweep bundle dw-other")
+ snapshot = path.read_text(encoding="utf-8")
+
+ assert not mark_open(path, "DW-1", "resolved by sweep bundle dw-mine")
+ assert not mark_open(path, "DW-2", "resolved by sweep bundle dw-mine")
+ assert not mark_open(path, "DW-2", "")
+ assert path.read_text(encoding="utf-8") == snapshot
+
+
+def test_mark_open_refuses_an_entry_that_is_still_open(tmp_path):
+ """The `entry.open` guard, which is NOT redundant with the note match. An entry
+ the inner session appended can carry a `resolution:` line while still open — this
+ module exists because the orchestrator never trusts an LLM to have edited the
+ file. Drop the guard and such an entry is rewritten and its note silently deleted,
+ which no caller asked for."""
+ path = tmp_path / "dw.md"
+ path.write_text(
+ "# Deferred Work\n\n### DW-1: partially handled\n\n"
+ "origin: a session, 2026-06-01\nstatus: open\nresolution: half of it landed\n",
+ encoding="utf-8",
+ )
+ snapshot = path.read_text(encoding="utf-8")
+ assert not mark_open(path, "DW-1", "half of it landed")
+ assert path.read_text(encoding="utf-8") == snapshot
+
+
+def test_mark_open_refuses_an_entry_with_no_status_line(tmp_path):
+ """`parse_ledger` tolerates a status-less entry (it reads as not open, so the
+ guard above does not catch it) and `mark_open` runs from `_defer`, where an
+ AttributeError on `status_m` would replace a deferral with a crashed run."""
+ path = tmp_path / "dw.md"
+ path.write_text(
+ "# Deferred Work\n\n### DW-1: no status at all\n\n"
+ "origin: a session, 2026-06-01\nresolution: by dw-a\n",
+ encoding="utf-8",
+ )
+ snapshot = path.read_text(encoding="utf-8")
+ assert not mark_open(path, "DW-1", "by dw-a")
+ assert path.read_text(encoding="utf-8") == snapshot
+
+
+def test_mark_open_tolerates_reformatted_whitespace_around_the_note(tmp_path):
+ """The note is compared stripped, so a ledger a session reformatted — trailing
+ whitespace on the resolution line — is still recognised as this caller's own
+ close rather than silently treated as someone else's."""
+ path = tmp_path / "dw.md"
+ path.write_text(
+ "# Deferred Work\n\n### DW-1: closed then reflowed\n\n"
+ "origin: a session, 2026-06-01\nstatus: done 2026-06-11\n"
+ "resolution: by dw-a \n",
+ encoding="utf-8",
+ )
+ assert mark_open(path, "DW-1", "by dw-a")
+ entries = {e.id: e for e in parse_ledger(path.read_text(encoding="utf-8"))}
+ assert entries["DW-1"].open and "resolution:" not in entries["DW-1"].body
+
+
+def test_mark_open_noop_on_open_and_missing_entries(tmp_path):
+ """Idempotent, so a resume that re-drives a deferred bundle cannot double-undo."""
+ path = write_ledger(tmp_path)
+ assert mark_done(path, "DW-1", "2026-06-11", "by dw-a")
+ assert mark_open(path, "DW-1", "by dw-a")
+ snapshot = path.read_text(encoding="utf-8")
+ assert not mark_open(path, "DW-1", "by dw-a") # already open
+ assert not mark_open(path, "DW-99", "by dw-a") # absent
+ assert not mark_open(tmp_path / "nope.md", "DW-1", "by dw-a") # no ledger
+ assert path.read_text(encoding="utf-8") == snapshot
+
+
def test_append_decision(tmp_path):
path = write_ledger(tmp_path)
assert append_decision(path, "DW-3", "2026-06-11", "Keep cap", "frozen intent stands")
@@ -450,6 +549,82 @@ def test_append_entry_not_blocked_when_prior_is_done(tmp_path):
assert new_id == "DW-2" # prior entry is done, not open → re-file allowed
+def test_append_entry_writes_location_between_source_spec_and_severity(tmp_path):
+ """`location:` is the canonical format's third field (origin → location →
+ severity → reason → status), so the harvested file:line lands where every
+ existing reader — the format doc, the TUI, sweep triage — expects it, not
+ appended wherever it happened to be convenient."""
+ p = tmp_path / "deferred-work.md"
+ new_id = append_entry(
+ p,
+ title="Retry loop can spin",
+ origin="spec-deferred abc123abc123",
+ source_spec="spec-1-1-a.md",
+ reason="the backoff has no ceiling",
+ location="src/retry.py:88",
+ severity="medium",
+ )
+ lines = [ln for ln in parse_ledger(p.read_text())[0].body.splitlines() if ln.strip()]
+ assert new_id == "DW-1"
+ assert lines == [
+ "### DW-1: Retry loop can spin",
+ "origin: spec-deferred abc123abc123",
+ "source_spec: `spec-1-1-a.md`",
+ "location: src/retry.py:88",
+ "severity: medium",
+ "reason: the backoff has no ceiling",
+ "status: open",
+ ]
+ assert field_line_present(parse_ledger(p.read_text())[0].body, "location", "src/retry.py:88")
+
+
+def test_append_entry_writes_n_a_when_location_is_absent(tmp_path):
+ """An entry with no location still carries a `location:` line, holding `n/a`.
+ deferred-work-format.md specifies exactly that value for an item with no
+ file:line, and marks only `severity:` optional — so omitting the line made
+ this writer the one producer disagreeing with the format the sweep skill
+ reads. The whole line list is asserted so the placeholder is pinned in the
+ canonical position, not merely present somewhere."""
+ p = tmp_path / "deferred-work.md"
+ append_entry(p, title="t", origin="o", source_spec="s.md", reason="r")
+ lines = [ln for ln in parse_ledger(p.read_text())[0].body.splitlines() if ln.strip()]
+ assert lines == [
+ "### DW-1: t",
+ "origin: o",
+ "source_spec: `s.md`",
+ "location: n/a",
+ "reason: r",
+ "status: open",
+ ]
+
+
+def test_append_entry_strips_a_location_before_deciding_it_is_empty(tmp_path):
+ """The value is stripped: a blank one reads as absent, a padded one is written
+ clean. Both are the same one-line guard, so this is its sole witness.
+
+ NEITHER half is reachable from the harvest, and saying so is the point —
+ this guard is a promise `append_entry` makes about its own `str | None`
+ signature, kept because it has two callers and one of them is not the
+ harvest. `devcontract._flatten` collapses runs and yields "" for a blank
+ scalar (and its caller maps "" to None), and since
+ `test_flatten_leaves_no_trailing_space_when_the_clamp_lands_on_one` it also
+ strips after clamping — the route that once made the trailing-space half
+ reachable. It was closed at the source deliberately: `location` feeds the
+ ledger AND `harvest_fingerprint`, so cleaning it only here would have left
+ the entry's `origin:` key underivable from the entry's own `location:`.
+
+ `field_line_present` tolerates padding around a value, which is exactly why
+ the second half asserts the raw text instead of going through it."""
+ p = tmp_path / "deferred-work.md"
+ append_entry(p, title="t", origin="o", source_spec="s.md", reason="r", location=" \t ")
+ assert field_line_present(parse_ledger(p.read_text())[0].body, "location", "n/a")
+
+ # a separate ledger: same origin + source_spec would hit the idempotency guard
+ q = tmp_path / "clamped.md"
+ append_entry(q, title="t", origin="o", source_spec="s.md", reason="r", location="src/a.py:1 ")
+ assert "location: src/a.py:1\n" in q.read_text()
+
+
def test_append_entry_creates_missing_ledger(tmp_path):
p = tmp_path / "sub" / "deferred-work.md"
new_id = append_entry(p, title="t", origin="o", source_spec="s.md", reason="r")
diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py
index b41d3f81..034b13b5 100644
--- a/tests/test_devcontract.py
+++ b/tests/test_devcontract.py
@@ -6,6 +6,7 @@
import pytest
from bmad_loop import devcontract
+from bmad_loop.verify import read_frontmatter
def _spec(
@@ -302,11 +303,14 @@ def test_find_artifact_missing_dir(tmp_path):
assert devcontract.find_result_artifact(tmp_path / "ghost", since_ns=0) is None
-def test_find_artifact_accepts_no_spec_fallback_prefix(tmp_path):
+@pytest.mark.parametrize("prefix", ["bmad-build-auto-result-", "bmad-dev-auto-result-"])
+def test_find_artifact_accepts_no_spec_fallback_prefix(tmp_path, prefix):
# The no-spec fallback (intent too unclear to create a spec) carries a terminal
# frontmatter status but NO `## Auto Run Result` heading — it is matched by its
- # `bmad-dev-auto-result-` filename prefix instead.
- fallback = tmp_path / "bmad-dev-auto-result-unclear-1234.md"
+ # `-result-` filename prefix instead. BOTH eras are matched: the
+ # artifact is named after whichever skill wrote it (BMAD-METHOD #2651 renamed
+ # bmad-dev-auto to bmad-build-auto), and a run can meet either.
+ fallback = tmp_path / f"{prefix}unclear-1234.md"
fallback.write_text(
"---\nstatus: blocked\n---\n\nBlocking condition: unclear intent\n",
encoding="utf-8",
@@ -715,3 +719,270 @@ def test_strip_auto_run_result_ignores_heading_in_mismatched_fence_char(tmp_path
sp.write_text(original, encoding="utf-8")
assert devcontract.strip_auto_run_result(sp) is False
assert sp.read_text() == original
+
+
+# ---------------------------------------- frontmatter `deferred:` harvest (#2640)
+#
+# Every case reads through the REAL `verify.read_frontmatter` the orchestrator
+# uses, over a spec written by the real `conftest.render_deferred` block-scalar
+# renderer. Parsing a hand-built dict instead would prove only that Python
+# dictionaries have keys — the contract under test is that upstream's `>-`/`|-`
+# serialization survives YAML into flat, ledger-shaped values.
+
+
+def _deferred_spec(path: Path, items, status: str = "done") -> dict:
+ from conftest import write_spec
+
+ write_spec(path, status, "abc123", deferred=items)
+ return read_frontmatter(path)
+
+
+def test_parse_deferred_findings_absent_field_is_empty(tmp_path):
+ """A pre-#2640 spec has no `deferred:` key at all. Indistinguishable from
+ "nothing was deferred" on purpose — the harvest is keyed on content, never on
+ which skill era wrote the spec."""
+ from conftest import write_spec
+
+ sp = tmp_path / "spec.md"
+ write_spec(sp, "done", "abc123")
+ assert "deferred" not in read_frontmatter(sp)
+ assert devcontract.parse_deferred_findings(read_frontmatter(sp)) == ([], [])
+
+
+def test_parse_deferred_findings_empty_and_null_lists_are_empty(tmp_path):
+ """`deferred: []` (the spec template's default) and an explicit YAML null are
+ both "nothing deferred", not malformed input."""
+ assert devcontract.parse_deferred_findings(_deferred_spec(tmp_path / "a.md", [])) == ([], [])
+ assert devcontract.parse_deferred_findings({"deferred": None}) == ([], [])
+
+
+def test_parse_deferred_findings_single_item_full_shape(tmp_path):
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [
+ {
+ "summary": "Retry loop can spin: no ceiling # really",
+ "evidence": "the backoff doubles forever",
+ "location": "src/retry.py:88",
+ "severity": "medium",
+ }
+ ],
+ )
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ assert malformed == []
+ assert len(findings) == 1
+ f = findings[0]
+ # the block scalar kept `:` and `#` as data — they are not YAML structure
+ assert f.summary == "Retry loop can spin: no ceiling # really"
+ assert f.evidence == "the backoff doubles forever"
+ assert f.location == "src/retry.py:88"
+ assert f.severity == "medium"
+ assert len(f.fingerprint) == 12
+
+
+def test_parse_deferred_findings_multiple_items_keep_order(tmp_path):
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [
+ {"summary": "first", "evidence": "e1"},
+ {"summary": "second", "evidence": "e2"},
+ {"summary": "third", "evidence": "e3"},
+ ],
+ )
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ assert malformed == []
+ assert [f.summary for f in findings] == ["first", "second", "third"]
+ # distinct summaries ⇒ distinct identities, so three ledger entries
+ assert len({f.fingerprint for f in findings}) == 3
+
+
+def test_parse_deferred_findings_flattens_multiline_block_scalars(tmp_path):
+ """`|-` keeps newlines, which a single ledger field line cannot. Flatten to
+ one whitespace-collapsed line: a raw newline inside `reason:` would end the
+ field as far as every ledger reader is concerned."""
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [{"summary": "summary line", "evidence": "first line\nsecond line\n\nfourth"}],
+ )
+ findings, _ = devcontract.parse_deferred_findings(fm)
+ assert findings[0].evidence == "first line second line fourth"
+ assert "\n" not in findings[0].evidence
+
+
+def test_every_dev_primitive_has_a_fallback_result_prefix():
+ """The name the orchestrator WRITES a completion marker under and the set of
+ names it will MATCH are independent literals in two modules. Nothing derived
+ one from the other until this test.
+
+ The failure this prevents is silent and total: `engine` names the marker
+ `f"{resolved_primitive}-result-{task_id}.md"`, and `find_result_artifact`
+ only looks at names starting with one of these prefixes. A marker outside the
+ set is not merely unmatched — it falls through to the `## Auto Run Result`
+ heading branch, which the workflow-completion contract never writes, so the
+ marker is invisible and every plugin workflow livelocks to
+ `session_timeout_min` with no error anywhere.
+
+ Subset, not equality, and the direction is the whole point: a primitive with
+ no prefix is the unreadable-marker bug, while a prefix with no primitive is a
+ RETIRED era deliberately kept matchable — the comment on
+ `FALLBACK_RESULT_PREFIXES` asks for exactly that, so a resume can read an
+ artifact written before an upstream upgrade.
+
+ Reading the constants off the module rather than restating them is what makes
+ this hold: a third `DEV_PRIMITIVE_*` name added tomorrow is enforced without
+ anyone remembering this file exists."""
+ from bmad_loop import install
+
+ primitives = {
+ v for n, v in vars(install).items() if n.startswith("DEV_PRIMITIVE_") and isinstance(v, str)
+ }
+ assert primitives, "no DEV_PRIMITIVE_* string constants found — has the naming changed?"
+ missing = {f"{p}-result-" for p in primitives} - set(devcontract.FALLBACK_RESULT_PREFIXES)
+ assert not missing, f"dev primitives whose completion marker cannot be read back: {missing}"
+
+
+def test_flatten_leaves_no_trailing_space_when_the_clamp_lands_on_one(tmp_path):
+ """The clamp runs after the join, so the cut can land on a join space — the
+ one way a "collapsed single line" comes back with trailing whitespace.
+
+ This is not cosmetic, and it is not the ledger writer's problem to mop up:
+ `location` is consumed TWICE from here — `harvest_fingerprint(summary,
+ location)` becomes the entry's `origin:` dedup key, and the same value is
+ written as the entry's `location:` line. `append_entry` strips what it
+ writes, so cleaning it only there would leave the key derived from a string
+ the ledger no longer contains — a fingerprint no reader of the entry could
+ reproduce. Stripping at the source keeps both consumers on one value.
+
+ The fixture has to build the collision by hand: it takes a value longer than
+ `_LOCATION_LIMIT` whose 200th character is a join space, which no short
+ location can express."""
+ padded = " ".join(["a" * 9] * 30) # 30 words -> the 200-char cut lands on a space
+ assert devcontract._flatten(padded, devcontract._LOCATION_LIMIT) == padded[:200].strip()
+ assert padded[:200].endswith(" "), "fixture no longer builds the failing shape"
+
+ fm = _deferred_spec(tmp_path / "spec.md", [{"summary": "s", "location": padded}])
+ findings, _ = devcontract.parse_deferred_findings(fm)
+ assert not findings[0].location.endswith(" ")
+ # the two consumers agree: the key is derivable from the value the ledger keeps
+ assert findings[0].fingerprint == devcontract.harvest_fingerprint("s", findings[0].location)
+
+
+def test_parse_deferred_findings_optional_fields_default_empty(tmp_path):
+ fm = _deferred_spec(tmp_path / "spec.md", [{"summary": "only a summary"}])
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ assert malformed == []
+ assert findings[0].evidence == "" and findings[0].location == ""
+ assert findings[0].severity == ""
+
+
+def test_parse_deferred_findings_normalizes_and_drops_severity(tmp_path):
+ """Severity goes through the ledger's own alias table, so `blocker`/`Major`
+ normalize and a token no ledger reader knows becomes "" (the format doc's
+ documented "unspecified") rather than being written through verbatim."""
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [
+ {"summary": "a", "severity": "blocker"},
+ {"summary": "b", "severity": "Major"},
+ {"summary": "c", "severity": "spicy"},
+ ],
+ )
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ assert malformed == []
+ assert [f.severity for f in findings] == ["critical", "high", ""]
+
+
+def test_parse_deferred_findings_isolates_malformed_items(tmp_path):
+ """A mangled item must cost only itself. Its well-formed siblings on BOTH
+ sides still parse — the whole point of reporting malformed items separately
+ instead of failing the list."""
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [
+ {"summary": "good one", "evidence": "e"},
+ "a bare string, not a mapping",
+ {"evidence": "an item with no summary"},
+ {"summary": " ", "evidence": "whitespace-only summary"},
+ {"summary": "good two", "evidence": "e"},
+ ],
+ )
+ findings, malformed = devcontract.parse_deferred_findings(fm)
+ assert [f.summary for f in findings] == ["good one", "good two"]
+ assert len(malformed) == 3
+ assert "item 2" in malformed[0] and "not a mapping" in malformed[0]
+ assert "item 3" in malformed[1] and "summary" in malformed[1]
+ assert "item 4" in malformed[2]
+
+
+def test_parse_deferred_findings_non_list_value_is_malformed(tmp_path):
+ """The skill wrote a scalar (or a mapping) where the contract says list.
+ Nothing is harvestable, but the loss must be reported, not silently zero."""
+ findings, malformed = devcontract.parse_deferred_findings({"deferred": "one finding"})
+ assert findings == []
+ assert len(malformed) == 1 and "not a list" in malformed[0]
+ assert devcontract.parse_deferred_findings({"deferred": {"summary": "x"}})[1] != []
+
+
+def test_parse_deferred_findings_clamps_long_values(tmp_path):
+ """The summary becomes a `### DW-n:` heading and the rest become field
+ lines, so every value is bounded. Clamping happens before fingerprinting —
+ see the stability test below."""
+ fm = _deferred_spec(
+ tmp_path / "spec.md",
+ [{"summary": "s" * 500, "evidence": "e" * 4000, "location": "l" * 500}],
+ )
+ findings, _ = devcontract.parse_deferred_findings(fm)
+ assert len(findings[0].summary) == 200
+ assert len(findings[0].evidence) == 1000
+ assert len(findings[0].location) == 200
+
+
+def test_deferred_fingerprint_stable_across_reparse_and_evidence_edits(tmp_path):
+ """The fingerprint is the ledger's dedup key, so it must be reproducible from
+ a re-read of the same spec — and must NOT move when only the evidence prose
+ changes, or a review pass rewording its own justification would re-file the
+ same finding as a second entry."""
+ first, _ = devcontract.parse_deferred_findings(
+ _deferred_spec(
+ tmp_path / "a.md", [{"summary": "same finding", "evidence": "e1", "location": "f.py:1"}]
+ )
+ )
+ again, _ = devcontract.parse_deferred_findings(
+ _deferred_spec(
+ tmp_path / "b.md", [{"summary": "same finding", "evidence": "e1", "location": "f.py:1"}]
+ )
+ )
+ reworded, _ = devcontract.parse_deferred_findings(
+ _deferred_spec(
+ tmp_path / "c.md",
+ [{"summary": "same finding", "evidence": "REWORDED", "location": "f.py:1"}],
+ )
+ )
+ moved, _ = devcontract.parse_deferred_findings(
+ _deferred_spec(
+ tmp_path / "d.md", [{"summary": "same finding", "evidence": "e1", "location": "f.py:2"}]
+ )
+ )
+ assert first[0].fingerprint == again[0].fingerprint == reworded[0].fingerprint
+ # location is part of the identity: the same complaint about a different site
+ # is a different piece of work
+ assert moved[0].fingerprint != first[0].fingerprint
+
+
+def test_deferred_fingerprint_clamped_summaries_do_not_collide_by_accident(tmp_path):
+ """Two summaries that differ only past the clamp point render as the SAME
+ ledger entry, so they must share one identity — otherwise the ledger grows a
+ fresh visually-identical entry on every harvest."""
+ long_a = {"summary": "x" * 300 + "AAA"}
+ long_b = {"summary": "x" * 300 + "BBB"}
+ a, _ = devcontract.parse_deferred_findings(_deferred_spec(tmp_path / "a.md", [long_a]))
+ b, _ = devcontract.parse_deferred_findings(_deferred_spec(tmp_path / "b.md", [long_b]))
+ assert a[0].summary == b[0].summary
+ assert a[0].fingerprint == b[0].fingerprint
+
+
+def test_harvest_fingerprint_is_nul_joined(tmp_path):
+ """Parts are NUL-separated so no part can impersonate a boundary: ("ab","c")
+ and ("a","bc") are different identities."""
+ assert devcontract.harvest_fingerprint("ab", "c") != devcontract.harvest_fingerprint("a", "bc")
+ assert devcontract.harvest_fingerprint("a", "b") == devcontract.harvest_fingerprint("a", "b")
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index 92640120..11733b5e 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -27,6 +27,15 @@
HOME_PATH = "/home/canaryuser/secret/proj"
CODE = "def steal_creds(token): return token"
SHA = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
+# Deliberately does NOT embed STORY_KEY: the harvest events journal a bare
+# `spec_path.name`, and a spec is free to be named after the feature rather than
+# the story. Such a name is identifier-shaped, so the scrub_json fallback emits
+# it verbatim, and the egress backstop cannot repair what was never aliased —
+# only per-field routing can. A name like `1.2-Acme….md` would be rescued by the
+# story key inside it and would prove nothing.
+SPEC_NAME = "AcmeVaultRotation.md"
+# The same spec as the reconcile kinds journal it: absolute, under the home path.
+SPEC_ABS = f"{HOME_PATH}/docs/stories/{SPEC_NAME}"
CANARIES = [
EMAIL,
@@ -48,6 +57,7 @@
"CANARY_FEEDBACK",
"CANARY_PATCH",
SHA,
+ "AcmeVaultRotation",
]
@@ -128,6 +138,12 @@ def _seed_run(root, run_id="20260627-120000-aaaa", *, extra_journal=None, sweeps
)
j.append("story-done", story_key=STORY_KEY, commit=SHA)
j.append("sprint-status-unknown-keys", keys=[STORY_KEY, "9.9-OtherSecret"])
+ j.append("spec-deferrals-harvested", story_key=STORY_KEY, spec=SPEC_NAME)
+ # The SAME spec, in the other shape a producer emits: the reconcile kinds
+ # journal `str(spec_path)`, which `verify.resolve_spec_path` returns
+ # absolute. Seeded here rather than in one test so the canary sweep covers
+ # the path shape too.
+ j.append("spec-status-reconciled", story_key=STORY_KEY, spec=SPEC_ABS)
for kind, fields in extra_journal or []:
j.append(kind, **fields)
@@ -187,6 +203,88 @@ def test_pseudonymization_is_stable_and_correlates(project):
assert alias in combined
+def test_spec_name_is_aliased_in_its_own_namespace(project):
+ """The `spec-*` journal kinds carry the spec's name, which is the customer's
+ feature name. `test_no_canary_leaks_anywhere` already proves it does not
+ ship; this pins HOW — a per-field alias in a `spec` namespace of its own, so
+ it never renders in the epic-less `story-` shape a reused "story"
+ namespace would give a filename. Nothing else can cover it: the value is not
+ in the legend until it is routed, so the egress backstop has no alias to
+ substitute.
+
+ The namespace, not just the routing, is what this pins: `fullmatch` on the
+ `spec-` prefix is what fails if the field is moved to "story". (Correlation
+ across the two shapes a producer emits is a separate contract with its own
+ witness below — this test's run seeds both, but neither of its assertions can
+ see the difference.)"""
+ run_dir = _seed_run(project.project)
+ diag, pseudo, combined = _render_all([run_dir])
+ alias = next(a for _ns, orig, a in pseudo.entries() if orig == SPEC_NAME)
+ assert re.fullmatch(r"spec-[0-9a-f]{12}", alias), alias
+ assert alias in combined
+ # distinct from the story alias, and not wearing its shape
+ assert alias != diag.runs[0].tasks[0].alias
+
+
+def test_one_spec_gets_one_alias_whichever_shape_it_was_journalled_in(project):
+ """A spec journalled as a bare basename by the harvest kinds and as an
+ absolute path by the reconcile kinds is ONE spec, and must read as one.
+
+ Aliasing is chosen over dropping precisely so a maintainer can follow one
+ identifier across events (`_JOURNAL_ALIAS_FIELDS`' own docstring says so), so
+ two aliases for one spec is not cosmetic — it is the feature failing silently
+ in a dump that looks correct. The seeded run carries both shapes; the fixture
+ can express the failure because `SPEC_ABS` and `SPEC_NAME` are different
+ strings that must nonetheless collapse to a single alias.
+
+ The legend assertion is the second half of the same fix and needs its own
+ line: normalizing to the basename is also what keeps the user's absolute home
+ path out of the `--legend` file. Before `spec` was routed at all, a path-shaped
+ value was rejected by `scrub_json` and never entered the map; routing it
+ without normalizing would have put it there."""
+ run_dir = _seed_run(project.project)
+ _diag, pseudo, _combined = _render_all([run_dir])
+ aliases = {a for _ns, orig, a in pseudo.entries() if SPEC_NAME in orig}
+ assert len(aliases) == 1, pseudo.entries()
+ originals = [orig for _ns, orig, _a in pseudo.entries() if SPEC_NAME in orig]
+ assert originals == [SPEC_NAME], originals
+ assert HOME_PATH not in str(pseudo.legend())
+
+
+def test_a_windows_spec_path_normalizes_to_the_same_alias(project):
+ """The basename split handles backslashes, on every platform.
+
+ A journal written on Windows is routinely read by `diagnose` on POSIX, where
+ `PurePath(r"C:\\Users\\a\\x.md").name` is the WHOLE string — so the split is
+ separator-agnostic by hand rather than delegated to pathlib. This test is the
+ Windows witness that runs on the POSIX lanes too: it asserts on strings, never
+ on the filesystem, so it must NOT be skipped off Windows — which is the only
+ reason the divergence is covered at all."""
+ pseudo = sanitize.Pseudonymizer(salt=b"fixed")
+ posix = diagnostics._scrub_entry(
+ {"kind": "spec-status-reconciled", "spec": f"/home/u/docs/{SPEC_NAME}"}, pseudo, {}, None
+ )
+ windows = diagnostics._scrub_entry(
+ {"kind": "spec-status-reconciled", "spec": f"C:\\Users\\u\\docs\\{SPEC_NAME}"},
+ pseudo,
+ {},
+ None,
+ )
+ bare = diagnostics._scrub_entry(
+ {"kind": "spec-deferrals-harvested", "spec": SPEC_NAME}, pseudo, {}, None
+ )
+ assert posix["spec"] == windows["spec"] == bare["spec"]
+ assert list(pseudo.legend().values()) == [SPEC_NAME]
+ # Totality: a value ending in a separator has an empty tail, and `alias()`
+ # passes "" through unaliased — the event would lose its only reference to
+ # the spec rather than gain one. The `or value` fallback owns this line.
+ trailing = diagnostics._scrub_entry(
+ {"kind": "spec-status-reconciled", "spec": "docs/"}, pseudo, {}, None
+ )
+ assert trailing["spec"] != ""
+ assert re.fullmatch(r"spec-[0-9a-f]{12}", trailing["spec"])
+
+
def test_structure_is_preserved(project):
run_dir = _seed_run(project.project)
diag, _pseudo, _combined = _render_all([run_dir])
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 155a7c12..c4a778fe 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -5497,3 +5497,1834 @@ def crashing_emit(stage, *args, **kwargs):
assert [s.role for s in adapter.sessions] == ["review"] # only the in-flight review ran
stops = [e for e in resumed.journal.entries() if e["kind"] == "run-stop"]
assert stops and stops[-1]["graceful"] is True
+
+
+# ============================================ dev-primitive name resolution (#405)
+# Upstream BMAD-METHOD #2651 renamed the dev primitive `bmad-dev-auto` →
+# `bmad-build-auto`, leaving a forwarding shim behind. The orchestrator therefore
+# spells the invoked name from what is on disk (Engine._dev_skill) instead of
+# hardcoding it, and must keep working against BOTH eras.
+
+
+def _prompt_task(project, **kw) -> StoryTask:
+ return StoryTask(story_key="1-1-a", epic=1, **kw)
+
+
+def test_dev_prompts_spell_the_post_rename_primitive(project):
+ """Every generic-dev leg (fresh, restore, repair) invokes the name resolved
+ from the dev adapter's skill tree — here the post-rename bmad-build-auto."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills")
+ engine, adapter = make_engine(project, [])
+ attach_profile(adapter)
+
+ fresh = engine._generic_dev_prompt(_prompt_task(project), None)
+ assert fresh == "/bmad-build-auto 1-1-a"
+
+ spec = str(project.implementation_artifacts / "spec-1-1-a.md")
+ restore = engine._generic_dev_prompt(
+ _prompt_task(project, spec_file=spec, restore_patch="/run/attempt.patch"), None
+ )
+ assert restore.startswith("/bmad-build-auto Resume review of the in-review spec")
+
+ feedback = project.implementation_artifacts / "feedback.md"
+ repair = engine._generic_dev_prompt(_prompt_task(project), feedback)
+ assert repair.startswith("/bmad-build-auto Resume the autonomous dev session")
+
+
+def test_dev_prompt_falls_back_to_the_legacy_name_without_a_profile(project):
+ """The no-profile shape (test fakes, and any adapter that carries no skill
+ tree) resolves to the pre-rename name. Pinned rather than incidental: it is
+ what keeps the rest of this suite — and a pre-rename target project whose
+ resolution fails open — dispatching a name that exists."""
+ from conftest import install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills") # present but unreachable
+ engine, adapter = make_engine(project, [])
+ assert getattr(adapter, "profile", None) is None
+
+ assert engine._generic_dev_prompt(_prompt_task(project), None) == "/bmad-dev-auto 1-1-a"
+
+
+def test_review_prompt_resolves_through_the_review_adapters_own_tree(project):
+ """A run can mix skill trees (dev=claude → .claude/skills, review=gemini →
+ .agents/skills) and the two trees can sit on different upstream eras. Each
+ prompt must spell the primitive ITS adapter would actually find, so the
+ per-role lookup and the per-tree memo are both load-bearing."""
+ from conftest import attach_profile, install_build_auto_skill, install_dev_base_skills
+
+ install_build_auto_skill(project.project, ".claude/skills")
+ install_dev_base_skills(project.project, ".agents/skills", folder_id=False)
+
+ run_dir = project.project / ".bmad-loop" / "runs" / "test-run"
+ dev = attach_profile(MockAdapter([]), "claude")
+ review = attach_profile(MockAdapter([]), "gemini")
+ engine = Engine(
+ paths=project,
+ policy=Policy(gates=GatesPolicy(mode="none"), notify=QUIET),
+ adapter=dev,
+ review_adapter=review,
+ run_dir=run_dir,
+ journal=Journal(run_dir),
+ state=RunState(run_id="test-run", project=str(project.project), started_at="now"),
+ )
+ spec = str(project.implementation_artifacts / "spec-1-1-a.md")
+
+ assert engine._generic_dev_prompt(_prompt_task(project), None) == "/bmad-build-auto 1-1-a"
+ assert engine._review_prompt(_prompt_task(project, spec_file=spec)).startswith(
+ f"/bmad-dev-auto {spec} —"
+ )
+ # both trees resolved independently and each was stat'd once
+ assert engine._dev_skill_cache == {
+ ".claude/skills": "bmad-build-auto",
+ ".agents/skills": "bmad-dev-auto",
+ }
+
+
+def test_review_prompt_never_asks_the_session_to_file_its_own_deferrals(project):
+ """Post-BMAD-METHOD#2640 the primitive records its `defer` findings in the
+ spec's frontmatter and `_harvest_spec_deferrals` files them; an affirmative
+ "append them to the ledger" here would file each finding a SECOND time,
+ with no possible dedup (`append_entry` matches on `origin:` + `source_spec:`
+ and an agent-written entry carries neither).
+
+ The prohibition on rewriting EXISTING entries stays — it is the prevention
+ side of SweepEngine._verify_review's reclose. And the prompt goes NEUTRAL
+ rather than adopting the sweep prompts' outright ban: on a pre-#2640 skill
+ there is no frontmatter to harvest, so the session's own flat append is the
+ only record and banning it would lose findings."""
+ engine, _ = make_engine(project, [])
+ spec = str(project.implementation_artifacts / "spec-1-1-a.md")
+
+ prompt = engine._review_prompt(_prompt_task(project, spec_file=spec))
+
+ assert prompt.startswith(f"/bmad-dev-auto {spec} —")
+ assert "append" not in prompt.lower() # no affirmative file-it-yourself clause
+ assert "do NOT modify, re-open, or rewrite existing deferred-work ledger" in prompt
+ assert "the orchestrator owns their status and resolution" in prompt
+
+
+# ------------------------- frontmatter `deferred:` harvest (BMAD-METHOD #2640)
+#
+# Upstream moved the dev primitive's `defer`-triaged review findings out of
+# deferred-work.md and into the spec's own frontmatter. The orchestrator harvests
+# them into the ledger, or the sweep pipeline silently starves. Gated on content +
+# the generic-dev seam, never on which skill era is installed.
+
+HARVEST_A = {
+ "summary": "Retry loop has no ceiling",
+ "evidence": "the backoff doubles forever: no cap",
+ "location": "src/retry.py:88",
+ "severity": "medium",
+}
+HARVEST_B = {
+ "summary": "Timeout is not configurable",
+ "evidence": "hardcoded 30s",
+ "location": "src/net.py:12",
+ "severity": "low",
+}
+
+
+def _harvest_policy(review: bool = False):
+ from bmad_loop.policy import DevPolicy, ReviewPolicy
+
+ return Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=review, trigger="always"),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ )
+
+
+def _ledger_entries(project):
+ from bmad_loop import deferredwork
+
+ text = (
+ project.deferred_work.read_text(encoding="utf-8") if project.deferred_work.is_file() else ""
+ )
+ return deferredwork.parse_ledger(text)
+
+
+def test_spec_frontmatter_deferrals_harvested_into_ledger(project):
+ """The happy path: a dev session that recorded a finding in its spec
+ frontmatter leaves an open, canonically-shaped ledger entry behind — with the
+ fingerprinted `origin:` marker later harvests dedup on."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ summary = engine.run()
+
+ assert summary.done == 1 and summary.deferred == 0
+ entries = _ledger_entries(project)
+ assert len(entries) == 1
+ body = entries[0].body
+ assert entries[0].title == "Retry loop has no ceiling"
+ assert entries[0].open
+ assert re.search(r"^origin: spec-deferred [0-9a-f]{12}$", body, re.M)
+ assert "source_spec: `spec-1-1-a.md`" in body
+ assert "location: src/retry.py:88" in body
+ assert "severity: medium" in body
+ assert "reason: the backoff doubles forever: no cap" in body
+ events = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(events) == 1
+ assert events[0]["dw_ids"] == [entries[0].id]
+ assert events[0]["deduped"] == 0 and events[0]["malformed"] == 0
+ assert events[0]["spec"] == "spec-1-1-a.md"
+
+
+def test_spec_deferrals_harvested_before_the_dev_decision(project):
+ """Ordering contract: the harvest runs inside the dev pass, ahead of the
+ verify/decide step — so a story that proceeds carries the ledger edit into
+ its squashed commit, and a story that RETRIES reverts it with the work it
+ describes and re-harvests on the next attempt. The retry half is the reset
+ plus an explicit restore, because the reset alone leaves a ledger the harvest
+ created (see test_harvest_untracked_ledger_reverted_on_non_fixable_retry). A
+ DEFER is the asymmetric case: `_defer` restores the ledger after its reset and
+ deliberately keeps the entry — see
+ test_defer_keeps_the_harvested_entry_after_rollback."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ assert kinds.index("spec-deferrals-harvested") < kinds.index("dev-decision")
+
+
+def test_defer_keeps_the_harvested_entry_after_rollback(project):
+ """The other half of the ordering contract, and the one that is NOT a
+ rollback: a story that harvests and then defers loses its code change to the
+ reset but keeps the ledger entry, because `_defer` restores the ledger it
+ snapshotted. That asymmetry is required — `_stash_deferred_artifacts` runs
+ first and moves the spec out of the artifacts dir, so after a defer the
+ `deferred:` frontmatter is no longer where a re-drive would re-harvest it and
+ the ledger entry is the finding's only surviving record."""
+ # committed, so the defer's `git reset --hard` genuinely reverts the harvest's
+ # edit and the restore is what puts the entry back (an untracked ledger would
+ # survive the reset on its own and prove nothing).
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("# Deferred Work\n", encoding="utf-8")
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "seed deferred-work")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])]
+ # never finalizes ⇒ the review budget runs out on a non-convergent story
+ # and the post-loop _verify_review gate defers instead of committing
+ + [review_effect(project, "1-1-a", clean=False, finalized=False) for _ in range(3)],
+ policy=_harvest_policy(review=True),
+ )
+ summary = engine.run()
+
+ assert summary.deferred == 1
+ # the work the finding describes is gone …
+ assert "change for 1-1-a" not in (project.project / "src.txt").read_text()
+ # … and the spec carrying the frontmatter is no longer harvestable
+ assert not (project.implementation_artifacts / "spec-1-1-a.md").exists()
+ # … but the harvested entry survived the reset
+ entries = _ledger_entries(project)
+ assert [e.title for e in entries] == ["Retry loop has no ceiling"]
+ assert entries[0].open
+ assert re.search(r"^origin: spec-deferred [0-9a-f]{12}$", entries[0].body, re.M)
+
+
+# A well-formed sha that is not any commit in the sandbox repo: the dev-artifact
+# gate compares it against the orchestrator's own baseline and retries NON-fixably.
+LYING_BASELINE = "deadbeef" * 5
+
+
+def _baseline_liar_effect(project, story_key: str = "1-1-a", *, deferred=None):
+ """A dev session that COMPLETES, does real work and finalizes its spec to
+ `done` — so the harvest fires — but stamps a baseline that is not the
+ orchestrator's. `_verify_dev_artifacts` then returns a retry with
+ ``fixable=False``, which is the branch that rolls the attempt back. (A
+ *fixable* failure — a failing verify command — keeps the attempt's tree
+ instead and never reaches `_rollback_or_pause`.)"""
+
+ def effect(spec):
+ source = project.project / "src.txt"
+ source.write_text(source.read_text() + f"change for {story_key}\n")
+ sp = spec_path(project, story_key)
+ write_spec(sp, "done", LYING_BASELINE, deferred=deferred)
+ return SessionResult(
+ status="completed",
+ result_json={
+ "workflow": "auto-dev",
+ "story_key": story_key,
+ "spec_file": str(sp),
+ "baseline_commit": LYING_BASELINE,
+ "tasks_total": 3,
+ "tasks_done": 3,
+ "verification": [],
+ "escalations": [],
+ "followup_review_recommended": False,
+ },
+ )
+
+ return effect
+
+
+def test_harvest_untracked_ledger_reverted_on_non_fixable_retry(project):
+ """#405: the harvest fires on the SPEC's status, ahead of the artifact gate
+ that can still send the attempt back — and the reset that discards the attempt
+ does not take the harvest with it. The ledger the harvest created is untracked,
+ and `_safe_reset`'s `keep` carries the artifact folders, so `safe_rollback`
+ computes it into `created` and then deliberately skips it. Left alone the entry
+ outlives the code it describes and rides into the story's squashed commit."""
+ assert not project.deferred_work.exists() # so the harvest CREATES it, untracked
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ # attempt 1: harvests HARVEST_A, then fails the baseline gate
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ # attempt 2: an honest session, so the run terminates cleanly
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ # the scenario really is a non-fixable retry that rolled back …
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "retry" and "does not match" in decisions[0]["reason"]
+ assert "rollback-auto" in [e["kind"] for e in engine.journal.entries()]
+ # … and attempt 1 did file the entry (attempt 2's spec carries no `deferred:`,
+ # so exactly one harvest ever fired — nothing here is a re-harvest)
+ harvests = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 1 and harvests[0]["dw_ids"] == ["DW-1"]
+
+ assert summary.done == 1
+ assert "change for 1-1-a" in (project.project / "src.txt").read_text() # attempt 2's
+ assert not project.deferred_work.exists()
+ sha = engine.state.tasks["1-1-a"].commit_sha
+ files = git(project.project, "show", "--name-only", "--pretty=format:", sha).split()
+ assert "_bmad-output/implementation-artifacts/deferred-work.md" not in files
+
+
+def test_harvest_tracked_ledger_still_reverted_on_retry(project):
+ """Control for the case that already worked: a ledger tracked at the attempt
+ baseline is reverted by the reset itself, and the restore writes back the same
+ bytes. The fix must not resurrect the harvest here (nor rewrite a file the
+ reset already put right)."""
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("# Deferred Work\n", encoding="utf-8")
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "seed deferred-work")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ assert [e["action"] for e in engine.journal.entries() if e["kind"] == "dev-decision"][0] == (
+ "retry"
+ )
+ assert project.deferred_work.read_text(encoding="utf-8") == "# Deferred Work\n"
+ assert _ledger_entries(project) == []
+
+
+def _gitignore_the_ledger(project) -> str:
+ """Make the deferred-work ledger IGNORED, commit the rule, and prove it took.
+
+ Committed on purpose: an uncommitted `.gitignore` edit is a tracked
+ modification, so the rollback's own `reset --hard baseline` reverts it — and by
+ the time the classifier runs, the ledger is plain-untracked again. The test would
+ then pass on the BUGGY build for entirely the wrong reason.
+
+ Ignoring the one file rather than the whole output folder keeps the spec and the
+ sprint board out of the rule; the code under test only ever asks about this one
+ path, so the two are equivalent here and the narrow rule has no collateral.
+
+ Returns the ledger's repo-relative POSIX rel — never `str()`: git speaks posix
+ rels, and a native-separator spelling silently misses every set membership on
+ Windows (see the sibling precondition below)."""
+ rel = project.deferred_work.relative_to(project.project).as_posix()
+ gitignore = project.project / ".gitignore"
+ gitignore.write_text(gitignore.read_text(encoding="utf-8") + rel + "\n", encoding="utf-8")
+ git(project.project, "add", ".gitignore")
+ git(project.project, "commit", "-q", "-m", "gitignore the deferred-work ledger")
+ assert git(project.project, "check-ignore", rel).strip() == rel
+ return rel
+
+
+def _crash_at_post_dev_verify(engine):
+ """Kill the host in the window Codex's crash-replay finding names: AFTER
+ `_harvest_spec_deferrals` has written the ledger and BEFORE `decide_dev` /
+ `_rollback_or_pause` run. The last `_save()` was `_run_session`'s, so the task
+ persists at DEV_RUNNING with a completed record — exactly what
+ `_resumable_session` replays."""
+ original_emit = engine._emit
+
+ def crashing_emit(stage, *args, **kwargs):
+ if stage == "post_dev_verify":
+ raise RuntimeError("host died between the harvest and the rollback")
+ return original_emit(stage, *args, **kwargs)
+
+ engine._emit = crashing_emit
+
+
+def test_harvest_untracked_ledger_reverted_across_a_crash_replay(project):
+ """On the replayed attempt the ledger on disk is already POST-harvest, so
+ re-reading it would "restore" the very edit the rollback exists to undo. The
+ snapshot the dead attempt took is therefore PERSISTED, and the replay writes
+ those bytes back: here the snapshot is None (the harvest created the file), so
+ the restore unlinks."""
+ assert not project.deferred_work.exists() # so the harvest CREATES it, untracked
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+
+ crashed = load_state(engine.run_dir).tasks["1-1-a"]
+ # DEV_VERIFY with an EMPTY spec_file: the artifact gate failed, so
+ # `_finish_inflight` skips `_resume_after_dev_verify` and routes to
+ # `_resumable_session`, which re-enters `_dev_phase` from the top
+ assert crashed.phase == Phase.DEV_VERIFY and not crashed.spec_file
+ assert _ledger_entries(project), "the dead attempt really did harvest"
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ summary = resumed.run()
+
+ assert summary.done == 1
+ kinds = [e["kind"] for e in resumed.journal.entries()]
+ assert "resume-verify" in kinds # it really was a replay, not a restart
+ assert _ledger_entries(project) == []
+ assert not project.deferred_work.exists()
+
+
+def test_harvest_tracked_ledger_reverted_across_a_crash_replay(project):
+ """With the ledger TRACKED, the replayed attempt's `reset --hard` already reverts
+ the dead attempt's harvest — so restoring a FRESHLY-READ snapshot would write it
+ straight back, making the revert *worse* across a crash than doing nothing at
+ all. The persisted snapshot predates the harvest, so writing it back is either a
+ no-op or the same revert the reset performed; the pre-existing committed entry
+ has to survive byte-for-byte to prove it reverted rather than truncated."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Pre-existing",
+ origin="an earlier sweep",
+ source_spec="specs/older.md",
+ reason="unrelated to this story",
+ )
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "seed deferred-work")
+ before = project.deferred_work.read_text(encoding="utf-8")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert HARVEST_A["summary"] in project.deferred_work.read_text(encoding="utf-8")
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ summary = resumed.run()
+
+ assert summary.done == 1
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+ assert [e.title for e in _ledger_entries(project)] == ["Pre-existing"]
+
+
+def test_crash_replay_restores_a_pre_existing_untracked_ledger(project):
+ """The third state, and the one a presence bit could not serve: a ledger the
+ operator already had on disk but never committed. It is untracked at the rollback
+ exactly like a harvest-created one, so an unlink here would delete real operator
+ content that no reset would have touched and no commit can restore — while
+ declining to act at all left the dead attempt's finding behind for the successful
+ retry to commit. Persisting the pre-harvest TEXT is what makes both requirements
+ satisfiable at once, so the assertion is byte-exact rather than a membership
+ test: anything less would pass on a build that merely appended nothing."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="never committed, never swept",
+ )
+ before = project.deferred_work.read_text(encoding="utf-8")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ git(project.project, "add", str(project.sprint_status.relative_to(project.project)))
+ git(project.project, "commit", "-q", "-m", "board only — the ledger stays untracked")
+ # as_posix, not str: `untracked_files` returns git's posix rels, so on Windows a
+ # native-separator rel is never in that set and the precondition self-fails —
+ # the same reason `_ledger_is_gits_to_restore` uses `.as_posix()`.
+ ledger_rel = project.deferred_work.relative_to(project.project).as_posix()
+ assert ledger_rel in verify.untracked_files(project.project)
+
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert HARVEST_A["summary"] in project.deferred_work.read_text(encoding="utf-8")
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ assert resumed.run().done == 1
+
+ titles = [e.title for e in _ledger_entries(project)]
+ assert "Operator's own note" in titles # the whole point: nothing of the operator's
+ assert HARVEST_A["summary"] not in titles # ...and the dead attempt's finding went
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_crash_replay_drops_a_gitignored_ledger_the_harvest_created(project):
+ """The fourth state, and the one git cannot describe: a GITIGNORED ledger.
+
+ Every git-side signal is ignore-blind — `verify.untracked_files` comes from
+ `git ls-files --others --exclude-standard`, so an ignored ledger is missing from
+ it whether the attempt created the file or not. Classifying on git alone drops
+ this one into the "not untracked ⇒ tracked ⇒ the reset already reverted it"
+ bucket, and that premise is false: `reset --hard` does not touch ignored files,
+ `safe_rollback` runs no `git clean`, and the artifacts dir is `keep`-shielded
+ besides. Nothing reverts the harvest and the dead attempt's finding outlives the
+ code it describes. The persisted snapshot is `None` here because the harvest
+ created the file, so the restore unlinks — and `_ledger_is_gits_to_restore` lets
+ it through precisely because git does NOT own an ignored path.
+
+ Not a hypothetical layout: the ledger lives under `output_folder`, and
+ gitignoring that folder is common (this repo does it) — `init` simply never
+ writes the rule itself, which is why the plain-untracked variant above was the
+ only one anyone modelled."""
+ ledger_rel = _gitignore_the_ledger(project)
+ assert not project.deferred_work.exists() # so the harvest CREATES it
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+
+ # Three preconditions, all load-bearing, all asserted only once the file exists.
+ # Without the last two a silently-failed ignore rule leaves the ledger
+ # plain-untracked, the untracked branch unlinks it, and this test goes green on
+ # the buggy build having proved nothing at all.
+ assert _ledger_entries(project), "the dead attempt really did harvest"
+ assert ledger_rel not in verify.untracked_files(project.project)
+ assert not verify.path_tracked(project.project, ledger_rel)
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ summary = resumed.run()
+
+ assert summary.done == 1
+ assert "resume-verify" in [e["kind"] for e in resumed.journal.entries()]
+ assert _ledger_entries(project) == []
+ assert not project.deferred_work.exists()
+
+
+def test_crash_replay_restores_a_pre_existing_gitignored_ledger(project):
+ """The same requirement one state further out, where git can say nothing at all:
+ an IGNORED ledger the operator already had is absent from `untracked_files` and
+ from `path_tracked` alike, exactly like a harvest-created one, so no git-derived
+ signal can separate the two. Reading the pre-harvest bytes off the filesystem is
+ the only answer that covers all four states. Do not delete this as redundant with
+ the untracked sibling: that one is at least visible to `git ls-files --others`,
+ and this one is invisible to every git probe there is."""
+ from bmad_loop import deferredwork
+
+ ledger_rel = _gitignore_the_ledger(project)
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="never committed, never swept — and gitignored besides",
+ )
+ before = project.deferred_work.read_text(encoding="utf-8")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ # ...and it really is invisible to git, not merely uncommitted: without this the
+ # test would "pass" through the plain-untracked shield and prove nothing new.
+ assert ledger_rel not in verify.untracked_files(project.project)
+ assert not verify.path_tracked(project.project, ledger_rel)
+
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert HARVEST_A["summary"] in project.deferred_work.read_text(encoding="utf-8")
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ assert resumed.run().done == 1
+
+ titles = [e.title for e in _ledger_entries(project)]
+ assert "Operator's own note" in titles # the whole point: nothing of the operator's
+ assert HARVEST_A["summary"] not in titles # ...and the dead attempt's finding went
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_crash_replay_keeps_a_tracked_ledger_the_reset_restored(project):
+ """The tracked guard's only ablation: a COMMITTED ledger the operator deleted
+ from the worktree without committing the deletion.
+
+ A `None` snapshot is a CREATION for an untracked or ignored ledger, but only a
+ modification for this one — the harvest re-creates the file, `reset --hard`
+ restores the committed bytes, and unlinking it here would turn a reverted edit
+ into a deleted file that the next attempt's `add -A` commits. Without
+ `_ledger_is_gits_to_restore` the unlink that serves the gitignored sibling would
+ trade a stale entry for lost content."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Committed note",
+ origin="an earlier sweep",
+ source_spec="specs/older.md",
+ reason="tracked, then deleted from the worktree",
+ )
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "seed deferred-work")
+ before = project.deferred_work.read_text(encoding="utf-8")
+ ledger_rel = project.deferred_work.relative_to(project.project).as_posix()
+ project.deferred_work.unlink() # the uncommitted deletion
+ assert verify.path_tracked(project.project, ledger_rel) # git still owns it
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert HARVEST_A["summary"] in project.deferred_work.read_text(encoding="utf-8")
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ assert resumed.run().done == 1
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_restore_ledger_keeps_a_tracked_ledger_the_reset_restored(project):
+ """The same trap on the LIVE path, which needs no crash and so is the more
+ reachable of the two: the pre-harvest snapshot is `None` because the tracked
+ ledger was deleted from the worktree, and an unconditional "None means unlink"
+ deletes the file `reset --hard` just restored.
+
+ Live and replayed now run the SAME restore over the same persisted bytes, so this
+ is the crash-free witness for that shared path rather than a second mechanism."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Committed note",
+ origin="an earlier sweep",
+ source_spec="specs/older.md",
+ reason="tracked, then deleted from the worktree",
+ )
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "seed deferred-work")
+ before = project.deferred_work.read_text(encoding="utf-8")
+ project.deferred_work.unlink()
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ assert [e["action"] for e in engine.journal.entries() if e["kind"] == "dev-decision"][0] == (
+ "retry"
+ )
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_crash_replay_leaves_the_ledger_alone_when_no_snapshot_was_persisted(project):
+ """A replay that finds nothing armed must not touch the ledger it did not see.
+
+ Two ways in, wanting the same answer: a task persisted before the fields existed,
+ and a host that died upstream of the pre-harvest save. Both leave the replay with
+ no idea what the ledger looked like *at the dead attempt's baseline*, and a guess
+ is worse than inaction — `pre_harvest_ledger` is None there, and None means
+ "unlink", so a restore gated on the TEXT instead of the FLAG would delete an
+ operator's ledger on the first replay after upgrade.
+
+ The unarmed replay now RE-ARMS from disk instead of proceeding blind (#405), so
+ `ledger-snapshot-missing` is no longer the observable — the ledger's contents are.
+ The outcome is identical for this case (the re-armed snapshot is the current text,
+ so the restore is a no-op) and strictly better if the replayed attempt fails in
+ turn: its own harvest then has something to revert to. What must NOT change is the
+ line below it: nothing here deletes or rewrites a ledger this run never snapshotted.
+
+ The state doc is round-tripped through `to_dict`/`from_dict` rather than poked on
+ the instance: the deserializer is where the one-token mistake lives."""
+ from bmad_loop import deferredwork
+ from bmad_loop.model import StoryTask
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert _ledger_entries(project), "the dead attempt really did harvest"
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ doc = resumed.state.tasks["1-1-a"].to_dict()
+ del doc["pre_harvest_ledger"] # state.json from before the fields existed
+ del doc["pre_harvest_ledger_captured"]
+ resumed.state.tasks["1-1-a"] = StoryTask.from_dict(doc)
+ assert resumed.run().done == 1
+
+ # THE assertion: the replay left the dead attempt's harvest exactly as it found it
+ assert [e.title for e in _ledger_entries(project)] == [HARVEST_A["summary"]]
+ # ...by re-arming from disk rather than reporting a miss and running blind
+ assert "ledger-snapshot-missing" not in [e["kind"] for e in resumed.journal.entries()]
+ assert isinstance(deferredwork.parse_ledger(""), list) # ledger stayed parseable
+
+
+def test_the_pre_harvest_snapshot_is_persisted_before_the_harvest_runs(project):
+ """The durability contract, stated directly because nothing else can see it.
+
+ `run()`'s `finally: self._save()` re-persists the armed fields on any crash an
+ in-process fixture can stage, so deleting the arm's own `_save()` leaves every
+ crash-replay test above green — only a hard kill, which no test can inject, would
+ notice. This test therefore reads state.json off disk at the moment the harvest
+ runs. If it is ever "simplified away" as redundant, that `_save()` becomes
+ unpinned and the whole persistence story rests on a soft-crash accident.
+
+ `(True, None)` and not merely `True`: the snapshot has to record that no ledger
+ existed, which is what makes the restore an unlink."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ seen = []
+ original_harvest = engine._harvest_spec_deferrals
+
+ def probing_harvest(task, result_json):
+ persisted = load_state(engine.run_dir).tasks[task.story_key]
+ seen.append((persisted.pre_harvest_ledger_captured, persisted.pre_harvest_ledger))
+ return original_harvest(task, result_json)
+
+ engine._harvest_spec_deferrals = probing_harvest
+ assert engine.run().done == 1
+
+ assert seen[0] == (True, None)
+
+
+def _crash_before_the_snapshot(engine, *, on_attempt=2):
+ """Kill the host UPSTREAM of the pre-harvest arm: `_run_session` has already
+ recorded and saved the session (so `_resumable_session` replays it) but
+ `_dev_phase` never reached the snapshot. The replayed attempt therefore carries
+ no snapshot of its own — which is exactly the state clear-site 2 has to leave
+ behind for it."""
+ original_run_session = engine._run_session
+
+ def crashing_run_session(task, *args, **kwargs):
+ result = original_run_session(task, *args, **kwargs)
+ if kwargs.get("role") == "dev" and task.attempt == on_attempt:
+ raise RuntimeError("host died between the session and the pre-harvest snapshot")
+ return result
+
+ engine._run_session = crashing_run_session
+
+
+def _session_authored_ledger_liar(project, title: str, story_key: str = "1-1-a"):
+ """A dev session that files its own ledger entry — a pre-BMAD-METHOD#2640 skill's
+ only way to record a finding — and then fails the baseline gate."""
+ liar = _baseline_liar_effect(project, story_key)
+
+ def effect(spec):
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title=title,
+ origin="the session itself",
+ source_spec="specs/older.md",
+ reason="filed by the session, not by the harvest",
+ )
+ return liar(spec)
+
+ return effect
+
+
+def test_a_replay_never_restores_an_earlier_attempts_snapshot(project):
+ """Clear site 2, and the reason the snapshot cannot simply be left armed.
+
+ Attempt 1 arms `None` (no ledger yet), harvests, rolls back and unlinks. Attempt
+ 2's session files an entry of its own and the host dies before that attempt
+ reaches its own arm. If attempt 1's snapshot were still armed, the replay would
+ read it as "there was no ledger" and delete a file attempt 1 never saw — content
+ no reset would have touched and no commit can restore. Disarming at the decision
+ makes the stale snapshot unreachable.
+
+ The replay then re-arms from disk rather than running with nothing armed (#405),
+ so what pins the behaviour is the surviving entry, not the
+ `ledger-snapshot-missing` line that used to stand in for it: the re-armed snapshot
+ IS "Session's own note", so any later restore on this attempt writes that back
+ rather than attempt 1's None."""
+ assert not project.deferred_work.exists()
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=3))
+ engine, _ = make_engine(
+ project,
+ [
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ _session_authored_ledger_liar(project, "Session's own note"),
+ ],
+ policy=pol,
+ )
+ _crash_before_the_snapshot(engine, on_attempt=2)
+ assert engine.run().crashed
+ # attempt 1's harvest really was reverted, and attempt 2's own entry is what is
+ # on disk when the host dies — so the two snapshots genuinely disagree
+ assert [e.title for e in _ledger_entries(project)] == ["Session's own note"]
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ assert resumed.run().done == 1
+
+ kinds = [e["kind"] for e in resumed.journal.entries()]
+ assert "resume-verify" in kinds # it really was a replay, not a restart
+ assert "ledger-snapshot-missing" not in kinds # ...and it re-armed rather than ran blind
+ # THE assertion: attempt 1's stale `None` never reached this file
+ assert [e.title for e in _ledger_entries(project)] == ["Session's own note"]
+
+
+def test_a_finished_story_carries_no_ledger_copy(project):
+ """Clear site 1. After a PROCEED the snapshot can never be consumed — the phase
+ returns and never re-enters that attempt — but an armed field is persisted state,
+ so without the clear every finished story would carry a full copy of the ledger
+ in state.json for the rest of the run. Read back off disk rather than from the
+ in-memory task: the copy that costs anything is the persisted one."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="on disk before the story ran, so the snapshot is non-empty",
+ )
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project, [dev_effect(project, "1-1-a", followup_review=False)], policy=_harvest_policy()
+ )
+ assert engine.run().done == 1
+
+ persisted = load_state(engine.run_dir).tasks["1-1-a"]
+ assert persisted.pre_harvest_ledger_captured is False
+ assert persisted.pre_harvest_ledger is None
+ # the disarm is bookkeeping only — it must not reach the file
+ assert [e.title for e in _ledger_entries(project)] == ["Operator's own note"]
+
+
+def test_the_proceed_disarm_is_persisted_before_the_review_leg(project):
+ """Clear site 1's own `_save()` — the one call site where the "never save after a
+ disarm" rule does NOT hold, and the only place it can be seen.
+
+ `run()`'s `finally: self._save()` re-persists the disarmed fields after any crash
+ an in-process fixture can stage, so `test_a_finished_story_carries_no_ledger_copy`
+ above stays GREEN with this `_save()` deleted — only a hard kill, which no test
+ can inject, would ever notice. This test therefore reads state.json off disk at
+ the first moment after the disarm, exactly as
+ `test_the_pre_harvest_snapshot_is_persisted_before_the_harvest_runs` does for the
+ arm.
+
+ Why this site and not the other three. A kill here resumes through
+ `_finish_inflight`'s FIRST branch — the phase is `DEV_VERIFY` and the artifact
+ gate has set `spec_file` — into `_resume_after_dev_verify` → `_review_and_commit`.
+ `_dev_phase` is never re-entered, so nothing ever consumes the snapshot and a full
+ copy of the ledger rides a terminal task in state.json for the rest of the run. At
+ the RETRY site the same kill replays that attempt, which still WANTS the snapshot;
+ that is why THAT site deliberately has no save, and why the rationale is scoped to
+ it rather than stated universally (#405)."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="on disk before the story ran, so an armed snapshot is non-empty",
+ )
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project, [dev_effect(project, "1-1-a", followup_review=False)], policy=_harvest_policy()
+ )
+ seen = []
+ original_emit = engine._emit
+
+ def probing_emit(stage, task=None, **fields):
+ if stage == "post_dev_phase" and task is not None:
+ persisted = load_state(engine.run_dir).tasks[task.story_key]
+ seen.append((persisted.pre_harvest_ledger_captured, persisted.pre_harvest_ledger))
+ return original_emit(stage, task, **fields)
+
+ engine._emit = probing_emit
+ assert engine.run().done == 1
+
+ assert seen == [(False, None)]
+
+
+def test_dev_leg_defer_keeps_its_harvested_entry_and_disarms(project):
+ """Clear site 3, and the DEFER asymmetry it sits beside.
+
+ A DEFER does NOT revert: `_stash_deferred_artifacts` has already moved the spec
+ out of the artifacts dir, so the ledger entry is the finding's only surviving
+ record, and `_defer` snapshots the ledger around its own reset to keep it.
+
+ The review-leg control (`test_defer_keeps_the_harvested_entry_after_rollback`)
+ cannot stand in for this one: by the time a review-leg defer runs, `_dev_phase`
+ has PROCEEDed and clear-site 1 has already disarmed, and that test's ledger is
+ tracked besides — so even an unguarded restore is a no-op there. This defers on
+ the DEV leg, at `max_dev_attempts=1`, with an UNTRACKED ledger the harvest
+ created, which is the only shape where a stray restore is visible."""
+ assert not project.deferred_work.exists() # so the harvest CREATES it, untracked
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=1))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ summary = engine.run()
+
+ assert summary.deferred == 1
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "defer" # the DEV leg, not the review leg
+ assert [e.title for e in _ledger_entries(project)] == [HARVEST_A["summary"]]
+ persisted = load_state(engine.run_dir).tasks["1-1-a"]
+ assert persisted.pre_harvest_ledger_captured is False
+
+
+def test_a_non_completed_attempt_never_touches_the_ledger(project):
+ """The arm lives inside the `result.status == "completed"` branch, so a session
+ that never returned a result has nothing armed — and the non-fixable RETRY leg it
+ lands on must leave the ledger completely alone. Gating the restore on the TEXT
+ rather than on the captured FLAG turns this into an unlink, because
+ `pre_harvest_ledger` is None here and None means "there was no ledger".
+
+ The replayed half of the same question is vacuous by construction rather than
+ merely untested: `_resumable_session` refuses any record that is not `completed`
+ with a `result_json`, and synthesizes `status="completed"` on the SessionResult it
+ hands back, so `_dev_phase` can never observe a replayed non-completed result."""
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="never committed, never swept",
+ )
+ before = project.deferred_work.read_text(encoding="utf-8")
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+
+ def stalled_after_writing(spec):
+ # dirty the tree so the rollback really resets rather than skipping clean —
+ # the reset is what leaves the `keep`-shielded ledger standing alone
+ source = project.project / "src.txt"
+ source.write_text(source.read_text() + "half-finished work\n")
+ return SessionResult(status="stalled")
+
+ engine, _ = make_engine(
+ project,
+ [stalled_after_writing, lambda spec: SessionResult(status="died")],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ # attempt 1 really did take the non-fixable RETRY leg, where the restore lives
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "retry" and decisions[0]["session_status"] == "stalled"
+ assert "rollback-auto" in [e["kind"] for e in engine.journal.entries()]
+ assert summary.deferred == 1
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_a_restarted_story_drops_the_previous_invocations_snapshot(project):
+ """Clear site 0, and the one hole it closes.
+
+ A completed session record with `result_json is None` refuses replay, so the
+ resume RESTARTS the story rather than continuing it — and re-enters `_dev_phase`
+ carrying the dead invocation's armed snapshot. That snapshot describes a ledger
+ two invocations old. It is only reachable when the restarted attempt's session
+ does not complete (a completing one re-arms over it), which is exactly the shape
+ staged here: without the entry-time disarm the stale `None` unlinks a ledger
+ written between the two invocations."""
+ from bmad_loop import deferredwork
+
+ assert not project.deferred_work.exists() # invocation 1 arms `None`
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=3))
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+
+ resumed, _ = resume_engine(
+ project,
+ engine,
+ [lambda spec: SessionResult(status="stalled") for _ in range(3)],
+ policy=pol,
+ )
+ # the record is completed but result-less, so `_resumable_session` refuses it
+ resumed.state.tasks["1-1-a"].sessions[-1].result_json = None
+ # ...and the operator writes a ledger in between the two invocations
+ project.deferred_work.unlink(missing_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title="Operator's own note",
+ origin="a human",
+ source_spec="specs/older.md",
+ reason="written after the crash, before the restart",
+ )
+ before = project.deferred_work.read_text(encoding="utf-8")
+ resumed.run()
+
+ kinds = [e["kind"] for e in resumed.journal.entries()]
+ assert "resume-restart" in kinds and "resume-verify" not in kinds
+ assert "rollback-auto" in kinds # the restarted attempt reached the restore leg
+ assert project.deferred_work.read_text(encoding="utf-8") == before
+
+
+def test_harvest_retry_reharvests_after_the_rollback(project):
+ """The revert must lose nothing, and must not reach onto the fixable branch.
+
+ Attempt 1 harvests and is rolled back — the entry goes with it. Attempt 2
+ re-harvests from the frontmatter the harvest never mutates (proving the revert
+ is not a lost finding), then fails a FIXABLE gate, which keeps its tree: its
+ ledger entry has to stay, because attempt 3's repair spec no longer lists the
+ finding and nothing would re-file it. One entry at the end, filed once."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ marker = project.project / "fixed.marker"
+ pol = dataclasses.replace(
+ _harvest_policy(),
+ limits=LimitsPolicy(max_dev_attempts=3),
+ verify=VerifyPolicy(commands=(_file_exists_cmd(marker),)),
+ )
+
+ def repairing_dev(spec):
+ marker.write_text("ok\n") # the fixable failure is fixed; no `deferred:`
+ return dev_effect(project, "1-1-a", followup_review=False)(spec)
+
+ engine, _ = make_engine(
+ project,
+ [
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A]),
+ repairing_dev,
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ actions = [e["action"] for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert actions == ["retry", "retry", "proceed"]
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ assert kinds.count("rollback-auto") == 1 # attempt 2's fixable retry kept its tree
+ harvests = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 2 # attempt 1's (reverted) and attempt 2's (kept)
+ assert [e.title for e in _ledger_entries(project)] == ["Retry loop has no ceiling"]
+ sha = engine.state.tasks["1-1-a"].commit_sha
+ files = git(project.project, "show", "--name-only", "--pretty=format:", sha).split()
+ assert "_bmad-output/implementation-artifacts/deferred-work.md" in files
+
+
+def test_harvest_reverted_when_rollback_pauses(project):
+ """`scm.rollback_on_failure` OFF is the DEFAULT, and it does not reset at all:
+ it prints manual-recovery instructions and raises out of
+ `_pause_for_manual_recovery`. So the restore has to sit in a `finally` to run
+ there — and it has to run, because the `git reset --hard` that notice prints
+ would leave the untracked ledger behind and the operator would resume onto a
+ finding about work they just discarded."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(
+ _harvest_policy(),
+ scm=ScmPolicy(rollback_on_failure=False),
+ limits=LimitsPolicy(max_dev_attempts=2),
+ )
+ engine, _ = make_engine(
+ project, [_baseline_liar_effect(project, deferred=[HARVEST_A])], policy=pol
+ )
+ summary = engine.run()
+
+ assert summary.paused
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ assert "rollback-manual-required" in kinds and "rollback-auto" not in kinds
+ # the pause path leaves the tree untouched — nothing was reset out from under
+ # the ledger, so only the restore can account for its absence
+ assert "change for 1-1-a" in (project.project / "src.txt").read_text()
+ assert [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert not project.deferred_work.exists()
+
+
+def test_spec_deferrals_land_in_the_squashed_story_commit(project):
+ """The ledger edit must be part of the story's one commit, not left dirty for
+ the next story's step-01 to HALT on."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+
+ sha = engine.state.tasks["1-1-a"].commit_sha
+ files = git(project.project, "show", "--name-only", "--pretty=format:", sha).split()
+ assert "_bmad-output/implementation-artifacts/deferred-work.md" in files
+ assert worktree_clean(project.project)
+
+
+def test_spec_deferrals_multiple_findings_file_one_entry_each(project):
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A, HARVEST_B])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+
+ entries = _ledger_entries(project)
+ assert [e.title for e in entries] == [
+ "Retry loop has no ceiling",
+ "Timeout is not configurable",
+ ]
+ assert {e.id for e in entries} == {"DW-1", "DW-2"}
+
+
+def test_spec_deferrals_absent_field_writes_no_ledger(project):
+ """A pre-#2640 spec (no `deferred:` key) must not so much as create the
+ ledger file — an empty deferred-work.md would read as "swept clean"."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(project, [dev_effect(project, "1-1-a")], policy=_harvest_policy())
+ engine.run()
+
+ assert not project.deferred_work.exists()
+ assert not [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+
+
+def test_spec_deferrals_replay_does_not_double_append(project):
+ """Crash-replay re-enters the dev pass with the same recorded result. The
+ fingerprinted origin makes a second harvest of the same spec a no-op."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+ task = engine.state.tasks["1-1-a"]
+ rj = task.sessions[0].result_json
+
+ engine._harvest_spec_deferrals(task, rj)
+
+ assert len(_ledger_entries(project)) == 1
+ events = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert [e["deduped"] for e in events] == [0, 1]
+ assert events[1]["dw_ids"] == []
+
+
+def test_spec_deferrals_dedup_sees_already_done_entries(project):
+ """The dedup pre-scan matches entries of EVERY status. append_entry's own
+ guard is open-only by design (a human reopening a topic should be able to
+ re-file), which is exactly wrong here: a harvest replayed after the entry was
+ swept done would file the identical finding a second time, forever."""
+ from bmad_loop import deferredwork
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+ task = engine.state.tasks["1-1-a"]
+ entry = _ledger_entries(project)[0]
+ # the sweep resolved it in a later run
+ assert deferredwork.mark_done(project.deferred_work, entry.id, "2026-07-30", "fixed")
+
+ engine._harvest_spec_deferrals(task, task.sessions[0].result_json)
+
+ entries = _ledger_entries(project)
+ assert len(entries) == 1
+ assert entries[0].status.startswith("done")
+
+
+def test_spec_deferrals_not_harvested_when_spec_not_at_success_status(project):
+ """Append-on-success, mirroring the ledger semantics the old step-04 had: a
+ session that left the spec short of `done` gets rolled back, so its findings
+ would describe code that no longer exists. They stay in the frontmatter and
+ harvest on the eventual successful re-drive."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=1))
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", final_status="blocked", deferred=[HARVEST_A])],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ assert summary.done == 0
+ assert not project.deferred_work.exists()
+ assert not [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+
+
+def test_review_pass_deferrals_harvested_and_deduped_across_both_sites(project):
+ """A review pass is another full dev-primitive run, so it defers into the same
+ frontmatter list. The dev leg's finding A must not be re-filed by the review
+ leg, and the review's new finding B must be filed exactly once."""
+
+ def review_with_b(spec):
+ sp = spec_path(project, "1-1-a")
+ write_spec(sp, "done", _spec_baseline(sp), deferred=[HARVEST_A, HARVEST_B])
+ set_sprint(project, "1-1-a", "done")
+ return SessionResult(
+ status="completed",
+ result_json={
+ "workflow": "auto-dev",
+ "story_key": "1-1-a",
+ "spec_file": str(sp),
+ "status": "done",
+ "followup_review_recommended": False,
+ "escalations": [],
+ },
+ )
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, adapter = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A]), review_with_b],
+ policy=_harvest_policy(review=True),
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ assert [s.role for s in adapter.sessions] == ["dev", "review"]
+ entries = _ledger_entries(project)
+ assert [e.title for e in entries] == [
+ "Retry loop has no ceiling",
+ "Timeout is not configurable",
+ ]
+ events = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(events) == 2
+ assert events[0]["dw_ids"] == ["DW-1"] and events[0]["deduped"] == 0
+ assert events[1]["dw_ids"] == ["DW-2"] and events[1]["deduped"] == 1
+
+
+def test_spec_deferrals_malformed_items_file_one_aggregated_entry(project):
+ """A mangled item is a real loss of recorded work. Its well-formed siblings
+ still file normally, and the loss itself reaches the human on the same
+ channel — one meta-entry per spec, not one per bad item."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [
+ dev_effect(
+ project,
+ "1-1-a",
+ deferred=[HARVEST_A, "a bare string", {"evidence": "no summary"}],
+ )
+ ],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+
+ entries = _ledger_entries(project)
+ assert len(entries) == 2
+ assert entries[0].title == "Retry loop has no ceiling"
+ meta = entries[1]
+ assert meta.title == "Unreadable `deferred:` items in spec-1-1-a.md"
+ assert meta.open
+ assert re.search(r"^origin: spec-deferred-malformed [0-9a-f]{12}$", meta.body, re.M)
+ assert "item 2" in meta.body and "item 3" in meta.body
+ bad = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-malformed"]
+ assert len(bad) == 1 and len(bad[0]["items"]) == 2
+ harvested = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert harvested[0]["malformed"] == 2
+
+
+def test_spec_deferrals_malformed_meta_entry_not_duplicated_on_replay(project):
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=["a bare string"])],
+ policy=_harvest_policy(),
+ )
+ engine.run()
+ task = engine.state.tasks["1-1-a"]
+
+ engine._harvest_spec_deferrals(task, task.sessions[0].result_json)
+
+ assert len(_ledger_entries(project)) == 1
+
+
+def test_spec_deferrals_unreadable_spec_degrades_without_harvest(project, monkeypatch):
+ """Observation degrades: an unreadable spec journals `spec-read-failed` at the
+ harvest site and files nothing — never a crash, and never a guess."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(project, [], policy=_harvest_policy())
+ sp = spec_path(project, "1-1-a")
+ sp.parent.mkdir(parents=True, exist_ok=True)
+ write_spec(sp, "done", "abc123", deferred=[HARVEST_A])
+ fault_read_text(monkeypatch, sp)
+ task = StoryTask(story_key="1-1-a", epic=1)
+
+ engine._harvest_spec_deferrals(task, {"spec_file": str(sp)})
+
+ assert not project.deferred_work.exists()
+ fails = [e for e in engine.journal.entries() if e["kind"] == "spec-read-failed"]
+ assert len(fails) == 1 and fails[0]["site"] == "spec-deferrals"
+
+
+def test_spec_deferrals_ledger_write_fault_crashes_the_run(project, monkeypatch):
+ """Repair writes raise. A ledger append that fails must reach run()'s crash
+ recorder — swallowing it would drop findings the session recorded and report
+ the story as clean."""
+ monkeypatch.setattr("bmad_loop.engine.kill_session", lambda rid: None)
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+
+ def boom(*a, **kw):
+ raise PermissionError(13, "Permission denied")
+
+ monkeypatch.setattr("bmad_loop.deferredwork.append_entry", boom)
+ engine, _ = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ summary = engine.run()
+
+ assert summary.crashed is True
+ assert load_state(engine.run_dir).crash_error.startswith("PermissionError")
+ assert "run-crash" in (engine.run_dir / "journal.jsonl").read_text()
+
+
+# --- the pre-harvest snapshot must not ride a human-scale pause ---
+#
+# `rollback_on_failure = off` is the PRODUCTION DEFAULT, and it does not roll back: it
+# leaves the tree for the operator and raises `RunPaused` out of
+# `_pause_for_manual_recovery`. That raise skips clear site 2, so before #405 the
+# attempt's pre-harvest snapshot stayed armed in `state.json` for however long the human
+# took — and `resume`, which replays that same attempt, wrote those stale bytes back over
+# whatever the operator had done in the meantime. Every ledger state loses work that way:
+# the `snapshot is None` arm UNLINKS, and the `snapshot is not None` arm OVERWRITES with
+# no `_ledger_is_gits_to_restore` guard at all, so even a TRACKED ledger is rewritten.
+#
+# Fixture values that are load-bearing here, each verified rather than assumed:
+# * `_baseline_liar_effect`, not a failing verify command — only a NON-fixable outcome
+# reaches `_rollback_or_pause`; a fixable one keeps the tree and never gets there.
+# * the pause-window write must DIFFER from the snapshot, or `_restore_ledger`'s
+# `current == snapshot` short-circuit returns early and the test passes either way.
+# * assert raw `read_text()`, not `_ledger_entries` titles: the parse would hide a
+# malformed or truncated restore, and an operator's note is not a `### DW-n` entry.
+
+
+def _pause_policy(attempts: int = 2):
+ """`_harvest_policy` with auto-rollback OFF — the production default, and the only
+ dev leg that hands the tree to a human and raises instead of resetting."""
+ return dataclasses.replace(
+ _harvest_policy(),
+ scm=ScmPolicy(rollback_on_failure=False),
+ limits=LimitsPolicy(max_dev_attempts=attempts),
+ )
+
+
+def _run_to_pause(project, *, attempts: int = 2):
+ """Drive one attempt that completes, harvests HARVEST_A, then fails the artifact
+ gate non-fixably — so the run pauses with the tree untouched."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [_baseline_liar_effect(project, deferred=[HARVEST_A])],
+ policy=_pause_policy(attempts),
+ )
+ summary = engine.run()
+ assert summary.paused, "the fixture must reach the manual-recovery pause"
+ return engine
+
+
+def _resume_once(project, engine):
+ """Resume the paused run. `resume_engine` reloads state from DISK, which is the
+ point: it reads whatever the pause persisted, so a disarm that was not `_save()`d
+ would still be armed here."""
+ resumed, _ = resume_engine(
+ project, engine, [_baseline_liar_effect(project, deferred=[HARVEST_A])]
+ )
+ resumed.run()
+ return resumed
+
+
+OPERATOR_NOTE = "\n## operator's own note, written while the run was paused\n"
+
+
+def test_an_operator_ledger_written_during_the_pause_survives_resume(project):
+ """T1a. The commonest shape: no ledger existed at the attempt's baseline, so the
+ snapshot is `None` — and `None` means UNLINK. The operator reads the pause notice,
+ starts a ledger of their own, and resumes; the stale snapshot deleted the file."""
+ assert not project.deferred_work.exists()
+ engine = _run_to_pause(project)
+ # the pause reverted the harvest, as it should — that is not what is under test
+ assert not project.deferred_work.exists()
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text(OPERATOR_NOTE, encoding="utf-8")
+
+ _resume_once(project, engine)
+
+ assert project.deferred_work.is_file(), "resume deleted the operator's ledger"
+ assert project.deferred_work.read_text(encoding="utf-8") == OPERATOR_NOTE
+
+
+def test_an_operator_append_to_a_pre_existing_ledger_survives_resume(project):
+ """T1b. A ledger that already existed, so the snapshot is TEXT and the stale-arm
+ failure is an overwrite rather than an unlink — the operator's append is dropped
+ and the file silently reverts to its pre-pause bytes."""
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("### DW-0: pre-existing\nstatus: open\n", encoding="utf-8")
+ before = project.deferred_work.read_text(encoding="utf-8")
+
+ engine = _run_to_pause(project)
+ project.deferred_work.write_text(before + OPERATOR_NOTE, encoding="utf-8")
+
+ _resume_once(project, engine)
+
+ assert project.deferred_work.read_text(encoding="utf-8") == before + OPERATOR_NOTE
+
+
+def test_an_operator_append_to_a_TRACKED_ledger_survives_resume(project):
+ """T1c, and the state the old guard never covered. `_ledger_is_gits_to_restore`
+ gates only the `snapshot is None` UNLINK; the overwrite arm has no such check, so a
+ ledger git tracks was rewritten from the stale snapshot just like an untracked one.
+ Committing it is the whole fixture — without the commit this is T1b again."""
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("### DW-0: committed\nstatus: open\n", encoding="utf-8")
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "track the ledger")
+ rel = project.deferred_work.relative_to(project.project).as_posix()
+ assert verify.path_tracked(project.project, rel), "the fixture must really be tracked"
+ before = project.deferred_work.read_text(encoding="utf-8")
+
+ engine = _run_to_pause(project)
+ project.deferred_work.write_text(before + OPERATOR_NOTE, encoding="utf-8")
+
+ _resume_once(project, engine)
+
+ assert project.deferred_work.read_text(encoding="utf-8") == before + OPERATOR_NOTE
+
+
+def test_an_operator_append_to_a_gitignored_ledger_survives_resume(project):
+ """T1d. A gitignored ledger is the case every git-side signal is blind to — it is
+ absent from `untracked_files` and `reset --hard` never touches it — so only the
+ snapshot logic can lose it, and it did."""
+ _gitignore_the_ledger(project)
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("### DW-0: ignored\nstatus: open\n", encoding="utf-8")
+ before = project.deferred_work.read_text(encoding="utf-8")
+
+ engine = _run_to_pause(project)
+ project.deferred_work.write_text(before + OPERATOR_NOTE, encoding="utf-8")
+
+ _resume_once(project, engine)
+
+ assert project.deferred_work.read_text(encoding="utf-8") == before + OPERATOR_NOTE
+
+
+def test_the_arm_is_spent_by_the_pause_and_not_re_used_by_later_resumes(project):
+ """T1e, the stickiness case. The old arm was never SPENT: nothing on the pause leg
+ cleared it, so the same stale bytes were written back on every resume, not just the
+ first. Three consecutive resumes, each with a fresh operator edit, each of which
+ must survive — one is not enough to catch a re-arm that latches."""
+ assert not project.deferred_work.exists()
+ engine = _run_to_pause(project, attempts=6)
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ current = engine
+ for i in range(3):
+ text = f"{OPERATOR_NOTE}round {i}\n"
+ project.deferred_work.write_text(text, encoding="utf-8")
+ current = _resume_once(project, current)
+ assert project.deferred_work.is_file(), f"resume {i} deleted the operator's ledger"
+ assert project.deferred_work.read_text(encoding="utf-8") == text, f"resume {i} lost it"
+
+
+def test_a_pause_with_no_harvest_at_all_still_leaves_the_ledger_alone(project):
+ """T1f. The arm is gated on `result.status == "completed"` and nothing else — not on
+ the harvest having anything to file — so a session with no `deferred:` findings arms
+ a snapshot too, and on the default in-place project that is EVERY paused attempt.
+ No harvest is required to reach the data loss; this is the widest shape of all."""
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ project.deferred_work.write_text("### DW-0: untouched by this run\n", encoding="utf-8")
+ before = project.deferred_work.read_text(encoding="utf-8")
+
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [_baseline_liar_effect(project)], # completes, NO deferred: findings
+ policy=_pause_policy(),
+ )
+ assert engine.run().paused
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ assert "spec-deferrals-harvested" not in kinds, "nothing was harvested"
+
+ project.deferred_work.write_text(before + OPERATOR_NOTE, encoding="utf-8")
+ resumed, _ = resume_engine(project, engine, [_baseline_liar_effect(project)])
+ resumed.run()
+
+ assert project.deferred_work.read_text(encoding="utf-8") == before + OPERATOR_NOTE
+
+
+def test_the_disarm_is_on_disk_before_the_run_even_records_the_pause(project):
+ """T1i. The disarm's own `_save()`, pinned the way the arm's already is.
+
+ `run()`'s `finally: self._save()` re-persists the whole state on the way out of
+ the pause handler, so deleting the `_save()` beside the disarm leaves every test
+ above green — the resume reads a disarmed state.json either way. Only a hard kill
+ in that window would notice, and no in-process fixture can inject one. So read
+ state.json off disk at the first thing that happens after `_dev_phase` re-raises:
+ the run-level `run-paused` journal line, which is written before that ambient save.
+
+ `(False, None)` and not merely `False`: `_disarm_ledger_snapshot` drops the TEXT
+ as well, and a disarm that left the bytes behind would keep a full ledger copy in
+ state.json for the whole life of the pause."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, _ = make_engine(
+ project,
+ [_baseline_liar_effect(project, deferred=[HARVEST_A])],
+ policy=_pause_policy(),
+ )
+ seen = []
+ original_append = engine.journal.append
+
+ def probing_append(kind, **fields):
+ if kind == "run-paused":
+ persisted = load_state(engine.run_dir).tasks["1-1-a"]
+ seen.append((persisted.pre_harvest_ledger_captured, persisted.pre_harvest_ledger))
+ return original_append(kind, **fields)
+
+ engine.journal.append = probing_append
+ assert engine.run().paused
+ assert seen == [(False, None)], "the pause left the snapshot armed on disk"
+
+
+def test_an_unarmed_replay_still_refuses_the_dead_attempts_harvest_as_work(project):
+ """T1h, and the one place the two commits of #405 touch the same `if`.
+
+ The arm above re-arms a replay that has nothing armed. `harvest_wrote_ledger`'s
+ per-attempt clear sits at the same site and must NOT ride that relaxation: this
+ replay's ledger diff is entirely the DEAD attempt's harvest, and its harvest
+ dedupes to nothing, so clearing the flag here hands the proof-of-work gate the
+ orchestrator's own write — the exact hazard the flag exists to refuse.
+
+ Same fixture as `test_a_replayed_harvest_that_deduped_still_excludes_the_ledger`
+ with one change: the two snapshot keys are dropped from the persisted doc, so the
+ replay arrives unarmed and the relaxed condition is `False or True` rather than
+ `False or False`. That single difference is why the sibling test cannot see this.
+
+ Neither existing crash-replay test can either, for two independent reasons — both
+ of the other unarmed-replay tests run `_baseline_liar_effect`, which writes
+ `src.txt`, so real work exists regardless; and the baseline-mismatch check returns
+ before the has-changes probe is ever reached. Hence `write_src=False` plus a
+ truthful baseline here."""
+ from bmad_loop.model import StoryTask
+
+ assert not project.deferred_work.exists()
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ dev_effect(
+ project, "1-1-a", followup_review=False, write_src=False, deferred=[HARVEST_A]
+ )
+ ],
+ policy=pol,
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert _ledger_entries(project), "the dead attempt really did harvest"
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ doc = resumed.state.tasks["1-1-a"].to_dict()
+ assert doc["harvest_wrote_ledger"] is True, "the dead attempt's flag must be persisted"
+ del doc["pre_harvest_ledger"] # host died upstream of the pre-harvest save
+ del doc["pre_harvest_ledger_captured"]
+ resumed.state.tasks["1-1-a"] = StoryTask.from_dict(doc)
+ summary = resumed.run()
+
+ kinds = [e["kind"] for e in resumed.journal.entries()]
+ assert "resume-verify" in kinds # it really was a replay, not a restart
+ harvests = [e for e in resumed.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 2 and harvests[-1]["dw_ids"] == [] # the replay filed nothing
+ # THE assertion: the gate still saw no work, on a replay that re-armed its snapshot
+ decisions = [e for e in resumed.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "retry"
+ assert "no changes" in decisions[0]["reason"]
+ assert summary.done == 1 # attempt 2 did real work
+
+
+# --- the harvest's own ledger write must not satisfy the dev proof-of-work gate ---
+#
+# `_harvest_spec_deferrals` runs four statements above `_verify_dev_artifacts`, and
+# `verify_dev_exclude_relpaths` deliberately leaves the ledger un-excluded (a story whose
+# whole authorized scope is ledger reconciliation must register as real work — see
+# `test_verify.py`'s KNOWN-BUG pair). Without `Engine._harvest_gate_exclude` the gate
+# cannot tell the ORCHESTRATOR's write from the SESSION's, and a session that finalized
+# its spec and changed no code proceeds to done on the strength of the harvest alone.
+
+
+def _session_authored_ledger_effect(project, title: str, story_key: str = "1-1-a"):
+ """An HONEST dev session whose entire diff is a ledger entry it wrote ITSELF, with
+ no `deferred:` frontmatter and no source edit — the ledger-only story shape that
+ `verify_dev_exclude_relpaths` refuses to exclude on purpose. The mirror image of the
+ harvest: same file, same attempt window, different author."""
+ inner = dev_effect(project, story_key, followup_review=False, write_src=False)
+
+ def effect(spec):
+ from bmad_loop import deferredwork
+
+ project.deferred_work.parent.mkdir(parents=True, exist_ok=True)
+ deferredwork.append_entry(
+ project.deferred_work,
+ title=title,
+ origin="the session itself",
+ source_spec="specs/older.md",
+ reason="filed by the session, not by the harvest",
+ )
+ return inner(spec)
+
+ return effect
+
+
+def test_harvest_alone_is_not_proof_of_work(project):
+ """T2a. A session that finalized its spec, changed no code, and recorded one
+ `deferred:` finding must RETRY — the only post-baseline diff is the ledger line the
+ ORCHESTRATOR's harvest wrote four statements above the gate.
+
+ Both fixture values are load-bearing and neither alone reddens: without
+ `write_src=False` there is real work and the gate passes for the right reason;
+ without `deferred=` nothing writes the ledger and the gate fails for the right
+ reason. Only the pair puts the orchestrator's own write in front of the gate."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, adapter = make_engine(
+ project,
+ [
+ dev_effect(
+ project, "1-1-a", followup_review=False, write_src=False, deferred=[HARVEST_A]
+ ),
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["retry", "proceed"]
+ assert "no changes" in decisions[0]["reason"]
+ # the harvest really did fire on the refused attempt — the exclusion is what the
+ # gate answered on, not an absent ledger
+ harvests = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert harvests[0]["dw_ids"] == ["DW-1"]
+ assert [s.role for s in adapter.sessions] == ["dev", "dev"]
+ assert summary.done == 1
+
+
+def test_no_code_and_no_deferrals_still_retries(project):
+ """T2b, control. The same empty session WITHOUT a `deferred:` finding — nothing
+ writes the ledger, so this reddens on a build with no exclusion at all. It pins the
+ half of T2a's fixture that is not about the harvest."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ dev_effect(project, "1-1-a", followup_review=False, write_src=False),
+ dev_effect(project, "1-1-a", followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["retry", "proceed"]
+ assert "no changes" in decisions[0]["reason"]
+ assert summary.done == 1
+ assert not _ledger_entries(project)
+
+
+def test_real_work_plus_a_harvest_still_proceeds(project):
+ """T2c, the over-exclusion guard. The exclusion is one path wide: a session that
+ harvested AND changed code passes on the code. An exclude that swallowed the diff —
+ a whole-folder pattern, say — would redden here and nowhere else."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, adapter = make_engine(
+ project,
+ [dev_effect(project, "1-1-a", followup_review=False, deferred=[HARVEST_A])],
+ policy=_harvest_policy(),
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["proceed"]
+ assert summary.done == 1
+ assert [s.role for s in adapter.sessions] == ["dev"]
+ assert [e.title for e in _ledger_entries(project)] == [HARVEST_A["summary"]]
+
+
+def test_a_session_authored_ledger_entry_is_still_proof_of_work(project):
+ """T2f, the KNOWN-BUG control, and the reason the exclusion keys on a flag rather
+ than on the ledger's path. This session wrote the ledger itself and touched nothing
+ else; it must PROCEED. Excluding the ledger unconditionally would re-introduce
+ `KNOWN-BUG-ledger-only-story-false-no-changes.md`, the exact false "no changes"
+ `verify_dev_exclude_relpaths` refuses to create."""
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ engine, adapter = make_engine(
+ project,
+ [_session_authored_ledger_effect(project, "Session's own note")],
+ policy=_harvest_policy(),
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["proceed"]
+ assert summary.done == 1
+ assert [s.role for s in adapter.sessions] == ["dev"]
+ assert [e.title for e in _ledger_entries(project)] == ["Session's own note"]
+
+
+def test_a_later_attempt_that_did_not_harvest_gets_no_exclusion(project):
+ """T2g, and the whole reason the flag is cleared per attempt rather than latched.
+
+ Attempt 1 harvests and is rolled back. Attempt 2 is a ledger-only story of the T2f
+ kind — no `deferred:` frontmatter, so nothing of the orchestrator's is in its diff —
+ and must PROCEED on its own ledger write. A flag that survived attempt 1 would
+ exclude attempt 2's honest work and defer the story."""
+ assert not project.deferred_work.exists()
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ # attempt 1: harvests HARVEST_A, then fails the baseline gate non-fixably
+ _baseline_liar_effect(project, deferred=[HARVEST_A]),
+ # attempt 2: no frontmatter findings, no source edit, one ledger entry
+ _session_authored_ledger_effect(project, "Session's own note"),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["retry", "proceed"]
+ assert summary.done == 1
+ # attempt 1's harvest really did fire and really was reverted, so the only entry
+ # left is attempt 2's own
+ harvests = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 1 and harvests[0]["dw_ids"] == ["DW-1"]
+ assert [e.title for e in _ledger_entries(project)] == ["Session's own note"]
+
+
+def test_a_replayed_harvest_that_deduped_still_excludes_the_ledger(project):
+ """T2e, and the reason the flag is PERSISTED rather than derived from the harvest's
+ own `filed` list.
+
+ The dead attempt harvested; the crash left those entries in the tree. The replay
+ re-runs the harvest, which dedupes every one of them against what is already on
+ disk, so `filed` comes back EMPTY while the ledger diff the gate sees is entirely
+ the orchestrator's. A flag recomputed from `filed` reads False there and the gate
+ passes on the engine's own write; read back off disk it stays True."""
+ assert not project.deferred_work.exists()
+ write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"})
+ pol = dataclasses.replace(_harvest_policy(), limits=LimitsPolicy(max_dev_attempts=2))
+ engine, _ = make_engine(
+ project,
+ [
+ dev_effect(
+ project, "1-1-a", followup_review=False, write_src=False, deferred=[HARVEST_A]
+ )
+ ],
+ policy=pol,
+ )
+ _crash_at_post_dev_verify(engine)
+ assert engine.run().crashed
+ assert _ledger_entries(project), "the dead attempt really did harvest"
+
+ resumed, _ = resume_engine(
+ project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol
+ )
+ summary = resumed.run()
+
+ kinds = [e["kind"] for e in resumed.journal.entries()]
+ assert "resume-verify" in kinds # it really was a replay, not a restart
+ # the replayed harvest filed NOTHING and the gate still refused the attempt. The
+ # journal is shared across the resume, so the dead attempt's own harvest is entry 0
+ # and the replay's is the last one.
+ harvests = [e for e in resumed.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 2 and harvests[0]["dw_ids"] == ["DW-1"]
+ assert harvests[-1]["dw_ids"] == [] and harvests[-1]["deduped"] == 1
+ decisions = [e for e in resumed.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "retry"
+ assert "no changes" in decisions[0]["reason"]
+ assert summary.done == 1
diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py
index e3ccac4b..513c6914 100644
--- a/tests/test_engine_worktree.py
+++ b/tests/test_engine_worktree.py
@@ -8,8 +8,12 @@
from __future__ import annotations
+import os
import shutil
+from collections.abc import Sequence
+from pathlib import Path
+import pytest
from conftest import (
_OK,
_exists_run,
@@ -26,6 +30,7 @@
from bmad_loop.adapters.base import SessionResult
from bmad_loop.adapters.mock import MockAdapter
from bmad_loop.engine import Engine
+from bmad_loop.install import BMAD_SCRIPTS_SEED_REL, CENTRAL_CONFIG_REL, renderer_stub_resolved
from bmad_loop.journal import Journal, load_state
from bmad_loop.model import Phase, RunState, SessionRecord, StoryTask, TokenUsage
from bmad_loop.policy import GatesPolicy, LimitsPolicy, NotifyPolicy, Policy, ScmPolicy
@@ -670,6 +675,1102 @@ def test_isolation_none_leaves_no_worktrees(project):
assert "worktree-opened" not in journal_kinds(engine)
+def test_worktree_run_carries_bmad_surface_and_never_commits_it(project):
+ """End-to-end: an UNTRACKED `_bmad/` in the main repo (what a project that
+ gitignores it has — and a worktree checks out tracked files only, so the
+ checkout has none) reaches the worktree the session actually runs in. The
+ renderer is handed that worktree as its project root and hard-fails when the
+ root has no `_bmad/`.
+
+ Deliberately not gitignored: that keeps the second half load-bearing. The
+ worktree's local git exclude is then the ONLY thing stopping the seed — and the
+ render output the session writes on top of it — from being swept into the story
+ commit by the unit's `git add -A`."""
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ bmad = project.project / "_bmad"
+ (bmad / "scripts").mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (bmad / "scripts" / "config_utils.py").write_text("# config", encoding="utf-8")
+
+ head_before = rev_parse_head(project.project)
+ seen: list[list[str]] = []
+ dev = wt_dev_effect(project, "1-1-a")
+
+ def dev_and_probe(spec):
+ wt = spec.cwd
+ seen.append(
+ [
+ rel
+ for rel in ("config.toml", "scripts/render_skill.py", "scripts/config_utils.py")
+ if (wt / "_bmad" / rel).is_file()
+ ]
+ )
+ # what the renderer writes mid-session, AFTER provisioning shielded the dir
+ out = wt / "_bmad" / "render" / "bmad-build-auto" / "sandbox-abc" / "gen"
+ out.mkdir(parents=True)
+ (out / "workflow.md").write_text("rendered", encoding="utf-8")
+ return dev(spec)
+
+ engine, _ = make_engine(
+ project, [dev_and_probe, wt_review_effect(project, "1-1-a", clean=True)]
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ assert seen == [["config.toml", "scripts/render_skill.py", "scripts/config_utils.py"]]
+ # every commit the run landed carries the code change and no _bmad/ path at all
+ files = git(
+ project.project, "log", "--pretty=format:", "--name-only", f"{head_before}..HEAD"
+ ).splitlines()
+ assert "src.txt" in files
+ assert not [f for f in files if f.startswith("_bmad/")]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_incomplete_bmad_scripts_seed_pauses_before_dispatch(project, tmp_path):
+ """A shared BMad install wired as a symlink out of the repo: the seed's
+ resolve-and-contain guard drops every file under `_bmad/scripts/`, so the
+ worktree gets `config.toml` and no renderer at all.
+
+ That is an ENVIRONMENT fault, not this story's: the seed reads the same repo for
+ every unit, so dispatching would walk the whole backlog into result-less Stops
+ one story at a time. The run pauses before `drive` is ever entered, and the
+ half-seeded worktree stays mounted for inspection.
+
+ The #2601 renderer stub is installed on purpose: the gate is conditional on the
+ resolved primitive actually rendering, so without a stub on disk this environment
+ costs the run nothing and must NOT pause — see the inline sibling below."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ # before commit_sprint, so the tree is tracked and the worktree checks it out
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=True)
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ shared = tmp_path / "shared-bmad-scripts"
+ shared.mkdir()
+ (shared / "render_skill.py").write_text("# render", encoding="utf-8")
+ (shared / "config_utils.py").write_text("# config", encoding="utf-8")
+ bmad = project.project / "_bmad"
+ bmad.mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts").symlink_to(shared, target_is_directory=True)
+ # the repo-side probe still sees the renderer through the symlink
+ assert (bmad / "scripts" / "render_skill.py").is_file()
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree with no renderer")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter) # one call arms dev AND review: they share the adapter
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ task = engine.state.tasks["1-1-a"]
+ assert task.phase == Phase.ESCALATED
+ # the sibling never ran: a pause stops the loop, it does not walk the backlog
+ # one result-less Stop at a time (tasks are created on dispatch, so an absent
+ # key IS the proof the loop stopped)
+ assert "1-1-b" not in engine.state.tasks
+ entries = Journal(engine.run_dir).entries()
+ kinds = [e["kind"] for e in entries]
+ assert "story-escalated" in kinds
+ # the reason names the renderer, which is the evidence the gate now actually has
+ escalated = [e for e in entries if e["kind"] == "story-escalated"]
+ assert "render_skill.py" in escalated[0]["reason"]
+ # …and names the leg that actually fired, not the whole sentinel tuple: the
+ # config landed, so sending the operator to look for it would be a lie
+ assert BMAD_SCRIPTS_SEED_REL in escalated[0]["reason"]
+ assert CENTRAL_CONFIG_REL not in escalated[0]["reason"]
+ # the seed's own report still went out, so the escalation reads as an
+ # ESCALATION of that report rather than replacing it
+ assert "worktree-seed-skipped" in kinds
+ # the worktree the operator has to inspect is still mounted, and it is the
+ # renderer half that is short — config.toml made it in, so "seeding ran"
+ assert (Path(task.worktree_path) / "_bmad" / "config.toml").is_file()
+ assert not (Path(task.worktree_path) / "_bmad" / "scripts" / "render_skill.py").exists()
+
+
+def _real_skill_dirs(project, *skills: str, tree: str = ".claude/skills") -> None:
+ """Lay skills down as ORDINARY in-repo dirs — the half `_symlink_skill_tree` cannot
+ express, and what the primitive-era gate needs to be visible at all.
+
+ A tree symlinked out WHOLE drops every skill at once, so the resolved primitive is
+ always among the casualties and the gate is right to pause. Only the MIXED shape
+ separates the two questions: the skill this run dispatches seeds normally, and the
+ one it never names does not."""
+ from bmad_loop.install import DEV_PRIMITIVE_MARKERS
+
+ for skill in skills:
+ real = project.project / tree / skill
+ real.mkdir(parents=True, exist_ok=True)
+ (real / "SKILL.md").write_text(f"# {skill}\n", encoding="utf-8")
+ for marker in DEV_PRIMITIVE_MARKERS:
+ (real / marker).write_text("x\n", encoding="utf-8")
+
+
+def _symlink_skill_tree(
+ project,
+ shared: Path,
+ tree: str = ".claude/skills",
+ *,
+ renderer_stub: bool = False,
+ skills: Sequence[str] | None = None,
+) -> None:
+ """Point upstream skills in ``tree`` at a shared install OUTSIDE the repo — how a
+ machine-wide BMad install is wired, and the one shape provisioning cannot follow.
+
+ The tree must be GITIGNORED (and so untracked) for this to bite: a committed
+ symlink is checked out into the worktree as a symlink and resolves there just
+ fine. It is the untracked case that provisioning has to copy, and the copy is
+ what the containment guard refuses.
+
+ ``skills`` defaults to everything a session dispatches. Pass an explicit list to
+ symlink out ONE skill beside real dirs (see :func:`_real_skill_dirs`); the default
+ cannot express that, which is why the era over-breadth shipped green."""
+ from conftest import RENDERER_STUB_SKILL_MD, RENDERER_WORKFLOW_MD
+
+ from bmad_loop.install import DEV_PRIMITIVE_MARKERS, DEV_PRIMITIVE_NEW, REVIEW_HUNTER_SKILLS
+
+ tree_dir = project.project / tree
+ tree_dir.mkdir(parents=True, exist_ok=True)
+ for skill in skills if skills is not None else (DEV_PRIMITIVE_NEW, *REVIEW_HUNTER_SKILLS):
+ real = shared / skill
+ real.mkdir(parents=True, exist_ok=True)
+ stub = renderer_stub and skill == DEV_PRIMITIVE_NEW
+ (real / "SKILL.md").write_text(
+ RENDERER_STUB_SKILL_MD if stub else f"# {skill}\n", encoding="utf-8"
+ )
+ if stub:
+ # same pairing as `install_build_auto_skill`: a #2601 stub without the
+ # `workflow.md` it composes from is a BROKEN install, not a leaner one
+ (real / "workflow.md").write_text(RENDERER_WORKFLOW_MD, encoding="utf-8")
+ for marker in DEV_PRIMITIVE_MARKERS:
+ (real / marker).write_text("x\n", encoding="utf-8")
+ (tree_dir / skill).symlink_to(real, target_is_directory=True)
+
+
+def _symlink_skill_file(
+ project,
+ shared: Path,
+ skill: str,
+ filename: str,
+ tree: str = ".claude/skills",
+) -> None:
+ """Replace ONE CHILD FILE of an already-real skill dir with a symlink to a file
+ outside the repo — the shape neither sibling helper can express.
+
+ :func:`_real_skill_dirs` lays a skill down whole and :func:`_symlink_skill_tree`
+ points the WHOLE dir out of the repo, so between them a skill is either seeded
+ completely or dropped completely. Neither can produce the state in between: a
+ worktree skill dir that is PRESENT and resolvable but SHORT of one file the repo
+ carries, because the per-file containment guard drops exactly the symlinked child
+ and copies every sibling. That is the shape a DIRECTORY-granular check calls
+ complete — the dir exists, so nothing is reported — and then dispatches a session
+ whose step-04 has no customization to read.
+
+ ``filename`` is deliberately free rather than a marker: since the seed gate asks
+ walk PARITY with the repo instead of a named required set, this helper has to be
+ able to drop a NON-marker child too (a step file, the renderer's `workflow.md`) —
+ the half the old surface could not see at all.
+
+ The target file is created, so the link is LIVE: the repo-side preflight stats
+ through it and passes, which is what makes the worktree the only short half.
+ """
+ target = shared / skill / filename
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text("x\n", encoding="utf-8")
+ link = project.project / tree / skill / filename
+ link.unlink(missing_ok=True)
+ link.symlink_to(target)
+
+
+def _symlink_skill_subdir(
+ project,
+ shared: Path,
+ skill: str,
+ subdir: str,
+ *files: str,
+ tree: str = ".claude/skills",
+) -> None:
+ """Replace one SUB-DIRECTORY of an already-real skill dir with a symlink to a
+ populated directory outside the repo.
+
+ The shape that separates a shared walk from a mirrored one, and the only helper
+ that can build it. ``iterdir()`` + ``is_dir()`` descends a symlinked sub-directory;
+ ``Path.rglob``/``**`` does not. So the copier walks INTO this dir and drops every
+ child on containment, while a parity check spelled as ``repo_skill.rglob("*")``
+ enumerates nothing under it and calls the worktree complete.
+
+ :func:`_symlink_skill_file` cannot stand in: a TOP-LEVEL symlinked file is
+ enumerated fine by rglob, so it stays green under exactly that ablation.
+ """
+ target = shared / skill / subdir
+ target.mkdir(parents=True, exist_ok=True)
+ for name in files:
+ (target / name).write_text("x\n", encoding="utf-8")
+ link = project.project / tree / skill / subdir
+ if link.is_dir() and not link.is_symlink():
+ shutil.rmtree(link)
+ link.symlink_to(target, target_is_directory=True)
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_skill_seed_pauses_before_dispatch(project, tmp_path):
+ """The skills half of the same fault the renderer sentinels catch, and the leg
+ nothing covered: `provision_worktree` copies BASE_SKILLS from the main repo behind
+ a containment guard, and a skill tree symlinked to a shared install outside the
+ repo resolves out of `repo_root` and is dropped — silently, because that guard's
+ only stated justification ("the run-start preflight reports it") is true of the
+ skill-genuinely-absent leg and FALSE of this one. The preflight stats through the
+ link and passes.
+
+ So a project that passed preflight dispatched into a worktree holding none of its
+ skills, and every session stalled on `Unknown command` having written nothing —
+ the exact failure `missing_base_skills` exists to prevent, reached anyway. Same
+ environment-fault shape as the renderer legs: the seed reads the same repo for
+ every unit, so no repair session fixes it and no later story escapes it."""
+ from conftest import attach_profile
+
+ from bmad_loop import install
+
+ # gitignored skill tree = the normal shape, and the one that bites (see helper)
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ # A COMPLETE renderer surface, deliberately: it keeps the renderer legs silent, so
+ # the escalation below can only have come from the skills probe — and it is what
+ # lets the era-gate ablation discriminate this test from its inline sibling.
+ bmad = project.project / "_bmad" / "scripts"
+ bmad.mkdir(parents=True)
+ (bmad / "render_skill.py").write_text("# render", encoding="utf-8")
+ (bmad / "config_utils.py").write_text("# config", encoding="utf-8")
+ (project.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ _symlink_skill_tree(project, tmp_path / "shared-bmad-install", renderer_stub=True)
+ # the repo-side preflight follows the symlinks and passes — which is exactly why
+ # the run reaches provisioning at all
+ assert install.missing_base_skills(project.project, [".claude/skills"]) == []
+ assert install.resolve_dev_primitive(project.project, ".claude/skills") is not None
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree carrying no upstream skills")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ task = engine.state.tasks["1-1-a"]
+ assert task.phase == Phase.ESCALATED
+ # the pause stopped the loop rather than walking the backlog into the same stall
+ assert "1-1-b" not in engine.state.tasks
+ entries = Journal(engine.run_dir).entries()
+ kinds = [e["kind"] for e in entries]
+ assert "story-escalated" in kinds
+ escalated = [e for e in entries if e["kind"] == "story-escalated"]
+ # names the skills that actually went missing, not the renderer surface: the two
+ # have different remediations, and _bmad/ is intact here
+ assert ".claude/skills/bmad-build-auto" in escalated[0]["reason"]
+ assert BMAD_SCRIPTS_SEED_REL not in escalated[0]["reason"]
+ assert CENTRAL_CONFIG_REL not in escalated[0]["reason"]
+ # the seed's own report still went out, so the escalation reads as an ESCALATION
+ # of that report rather than a replacement for it
+ assert "worktree-seed-skipped" in kinds
+ # the worktree stays mounted for inspection, and it really is empty of skills
+ assert not (Path(task.worktree_path) / ".claude" / "skills" / "bmad-build-auto").exists()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_skill_seed_pauses_an_inline_primitive_too(project, tmp_path):
+ """The delta from the renderer legs, and the thing most likely to be got wrong by
+ copying them: this gate carries NO era condition. `_bmad/` is absent entirely, so
+ `renderer_stub_resolved` is False and the renderer branch cannot fire — yet an
+ inline `SKILL.md` the worktree does not have stalls a session exactly as hard as a
+ renderer stub it does not have. Gating this on the renderer, as the sentinel legs
+ are, would let the whole pre-#2601 world dispatch into the stall."""
+ from conftest import attach_profile
+
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ _symlink_skill_tree(project, tmp_path / "shared-bmad-install")
+ assert not (project.project / "_bmad").exists() # nothing renderer-era anywhere
+ assert not renderer_stub_resolved(project.project, [".claude/skills"])
+
+ def never(spec):
+ raise AssertionError("dispatched into a worktree carrying no upstream skills")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ escalated = [e for e in Journal(engine.run_dir).entries() if e["kind"] == "story-escalated"]
+ # names the PRIMITIVE, not just the tree: the three hunters are dropped here too, so
+ # a bare `.claude/skills/` substring passes even when the primitive stopped being
+ # checked at all — which is how this assert survived an ablation of that very leg
+ assert escalated and ".claude/skills/bmad-build-auto" in escalated[0]["reason"]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_an_unused_primitive_era_dropped_by_the_seed_does_not_pause(project, tmp_path):
+ """The other boundary, and the one the gate overran when it shipped: BASE_SKILLS is
+ a copy-if-PRESENT catalog listing both primitive eras, so reading it as a
+ requirement set pauses a run over a skill nothing dispatches.
+
+ Here every skill a session names — the resolved `bmad-build-auto`, the three inline
+ hunters, the merged `bmad-review` — is an ordinary in-repo dir that seeds normally.
+ Only the leftover `bmad-dev-auto` shim is symlinked to a shared install and dropped,
+ and post-rename no prompt ever spells it (`Engine._dev_skill` resolves the name from
+ disk). Nothing can stall, so nothing may pause: this project passed the preflight
+ and is not broken.
+
+ The two tests above cannot see this — `_symlink_skill_tree` drops the whole
+ dispatched set at once, so the resolved primitive is always among the casualties."""
+ from conftest import attach_profile
+
+ from bmad_loop import install
+
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ _real_skill_dirs(
+ project, install.DEV_PRIMITIVE_NEW, *install.REVIEW_HUNTER_SKILLS, "bmad-review"
+ )
+ # the ONE skill outside the repo is the era this run will never invoke — and it is
+ # marker-COMPLETE, so it is a skill that would resolve if anything asked for it
+ _symlink_skill_tree(
+ project, tmp_path / "shared-bmad-install", skills=[install.DEV_PRIMITIVE_LEGACY]
+ )
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ tree = ".claude/skills"
+ # the name every prompt spells, resolved the way the engine resolves it
+ assert install.dev_primitive_or_default(project.project, tree) == install.DEV_PRIMITIVE_NEW
+ assert install.missing_base_skills(project.project, [tree]) == []
+
+ engine, adapter = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ )
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert not summary.paused and summary.escalated == 0
+ assert engine.state.tasks["1-1-a"].phase == Phase.DONE
+ assert "story-escalated" not in [e["kind"] for e in Journal(engine.run_dir).entries()]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_tracked_symlinked_skill_tree_does_not_pause(project, tmp_path):
+ """Control, and the boundary the gate must not overrun: the identical symlinks,
+ COMMITTED. Git stores a symlink as a symlink, so the worktree checks it out and
+ the skill resolves there — nothing was dropped and the run must proceed.
+
+ Without this, a gate that simply refused every symlinked tree would pass the two
+ tests above while breaking every project that commits one."""
+ from conftest import attach_profile
+
+ _symlink_skill_tree(project, tmp_path / "shared-bmad-install")
+ commit_sprint(project, {"1-1-a": "ready-for-dev"}) # tracks the symlinks
+
+ engine, adapter = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ )
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert not summary.paused and summary.escalated == 0
+ assert engine.state.tasks["1-1-a"].phase == Phase.DONE
+ # nothing was reported short, which is the gate's own evidence that the worktree
+ # resolved the skill through the checked-out symlink (a successful run tears its
+ # worktree down, so the tree itself is gone by now — the journal is the record)
+ kinds = [e["kind"] for e in Journal(engine.run_dir).entries()]
+ assert "story-escalated" not in kinds
+ assert "worktree-seed-skipped" not in kinds
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_short_skill_dir_pauses_before_dispatch(project, tmp_path):
+ """The granularity leg: the worktree HAS the dev primitive's directory, and it is
+ still unusable. Only `customize.toml` is symlinked to a shared install outside the
+ repo, so the per-file containment guard drops that one child and copies SKILL.md
+ and the step files normally.
+
+ A directory-granular seed (skip the whole dir when the destination exists) and a
+ directory-granular gate (ask only whether the dir is there) both call that state
+ complete, so the session dispatches and stalls INSIDE the workflow rather than on
+ `Unknown command` — having written nothing, on this story and, since the seed
+ reads the same repo every time, on every story after it. Same environment-fault
+ shape as the whole-skill case, one layer down.
+
+ The repo passes its own preflight — it stats through the link — which is the whole
+ reason the run reaches provisioning. And `_bmad/` is absent entirely, so no
+ renderer leg can be what fired."""
+ from conftest import attach_profile
+
+ from bmad_loop import install
+ from bmad_loop.install import base_skills_seed_incomplete
+
+ # gitignored skill tree = the normal shape, and the only one provisioning copies:
+ # a tracked tree is checked out and there is nothing for the guard to drop
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ _real_skill_dirs(project, install.DEV_PRIMITIVE_NEW, *install.REVIEW_HUNTER_SKILLS)
+ # ...then swap exactly ONE required file of the resolved primitive for a link out
+ _symlink_skill_file(
+ project, tmp_path / "shared-bmad-install", install.DEV_PRIMITIVE_NEW, "customize.toml"
+ )
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ # the repo-side preflight follows the link and is HAPPY — that is the point: the
+ # repo passes and the worktree does not
+ assert install.missing_base_skills(project.project, [".claude/skills"]) == []
+ assert install.resolve_dev_primitive(project.project, ".claude/skills") is not None
+ # nothing renderer-era anywhere, so this is unambiguously the skills gate
+ assert not renderer_stub_resolved(project.project, [".claude/skills"])
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree whose primitive dir is short")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ task = engine.state.tasks["1-1-a"]
+ assert task.phase == Phase.ESCALATED
+ # the pause stopped the loop rather than walking the backlog into the same stall
+ assert "1-1-b" not in engine.state.tasks
+ entries = Journal(engine.run_dir).entries()
+ assert "story-escalated" in [e["kind"] for e in entries]
+ escalated = [e for e in entries if e["kind"] == "story-escalated"]
+ # the FULL rel, including the file. A bare `.claude/skills/bmad-build-auto` is the
+ # COARSE rel this same gate emits for a wholly-absent skill, so asserting only that
+ # would survive an ablation back to directory granularity — which is precisely the
+ # bug this test exists for.
+ assert ".claude/skills/bmad-build-auto/customize.toml" in escalated[0]["reason"]
+ # re-probe the predicate against the still-mounted worktree with an exact ==, so
+ # extra rels (a second dropped file, the coarse rel alongside the fine one) cannot
+ # hide behind a substring match on the prose above
+ assert base_skills_seed_incomplete(
+ Path(task.worktree_path), project.project, [".claude/skills"]
+ ) == [".claude/skills/bmad-build-auto/customize.toml"]
+ # ...and the rest of the dir really did arrive: this is a SHORT skill dir, not an
+ # absent one, which is the only thing that makes the granularity the subject
+ primitive = Path(task.worktree_path) / ".claude" / "skills" / "bmad-build-auto"
+ assert (primitive / "SKILL.md").is_file()
+ assert (primitive / "step-04-review.md").is_file()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_non_marker_file_pauses_before_dispatch(project, tmp_path):
+ """The nine-twelfths the old required set could not see. `step-01-clarify-and-route
+ .md` is not a :data:`DEV_PRIMITIVE_MARKER`, so a gate asking for `SKILL.md` plus the
+ two markers finds this dir complete and dispatches — the sibling test above passes
+ unchanged under that gate because `customize.toml` happens to be a marker.
+
+ The dropped file is not inert. It is the primitive's routing step: under a #2601
+ stub `workflow.md` names it as a `[[bmad-snapshot:…]]` source and `render_skill.py`
+ prints `HALT:` for an undeclared one; under an inline `SKILL.md` the session just
+ reads a step that is not there. Either way the story Stops having written nothing,
+ and since the seed reads the same repo every time, so does every story after it.
+
+ Walk parity with the copier covers all thirteen files and cannot fall behind an
+ upstream that renames a step — which is the argument for the reversal, made
+ executable."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ from bmad_loop import install
+ from bmad_loop.install import base_skills_seed_incomplete
+
+ dropped = "step-01-clarify-and-route.md"
+ # the whole premise: the old surface was SKILL.md + these two, and this is neither
+ assert dropped not in install.DEV_PRIMITIVE_MARKERS
+
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ # the real 12-file primitive, INLINE era, so no renderer leg can be what fires
+ install_build_auto_skill(project.project)
+ _real_skill_dirs(project, *install.REVIEW_HUNTER_SKILLS)
+ _symlink_skill_file(
+ project, tmp_path / "shared-bmad-install", install.DEV_PRIMITIVE_NEW, dropped
+ )
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ # the repo passes its own preflight — it stats through the link
+ assert install.missing_base_skills(project.project, [".claude/skills"]) == []
+ assert not renderer_stub_resolved(project.project, [".claude/skills"])
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree missing the primitive's step-01")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ task = engine.state.tasks["1-1-a"]
+ assert task.phase == Phase.ESCALATED
+ assert "1-1-b" not in engine.state.tasks
+ escalated = [e for e in Journal(engine.run_dir).entries() if e["kind"] == "story-escalated"]
+ assert escalated and f".claude/skills/bmad-build-auto/{dropped}" in escalated[0]["reason"]
+ # exact ==, so the coarse rel or a second casualty cannot hide behind the substring
+ assert base_skills_seed_incomplete(
+ Path(task.worktree_path), project.project, [".claude/skills"]
+ ) == [f".claude/skills/bmad-build-auto/{dropped}"]
+ # a SHORT dir, not an absent one: every sibling — markers and non-markers alike —
+ # landed, which is what makes the dropped NON-marker the subject
+ primitive = Path(task.worktree_path) / ".claude" / "skills" / "bmad-build-auto"
+ assert (primitive / "SKILL.md").is_file()
+ assert (primitive / "step-04-review.md").is_file()
+ assert (primitive / "step-02-plan.md").is_file()
+ assert (primitive / "review-prompts" / "adversarial.md").is_file()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_symlinked_out_skill_subdir_pauses_before_dispatch(project, tmp_path):
+ """The discriminator between a SHARED walk and a mirrored one, and the reason the
+ gate calls the copier's own generator instead of restating it as an `rglob`.
+
+ `iterdir()` + `is_dir()` descends a symlinked sub-directory; `Path.rglob`/`**` does
+ not, on any supported Python. So the copier walks INTO `review-prompts/`, drops
+ every child on containment, and an rglob-spelled parity check enumerates nothing
+ under it and reports a complete worktree — dispatching a step-04 whose review
+ prompts were never delivered. A shared install wired as a symlinked sub-tree is the
+ ordinary shape of this, not a contrived one.
+
+ ⚠️ NO Windows analogue exists: a symlinked sub-directory cannot be built on Windows
+ CI, so ablation A2 (walk → rglob) has no witness there. Stating the gap rather than
+ leaving it implicit — `test_a_dropped_nested_file_is_reported_with_a_posix_rel` in
+ test_install.py is the symlink-free Windows witness for the SEPARATOR ablation, and
+ covers nothing about this one."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ from bmad_loop import install
+ from bmad_loop.install import base_skills_seed_incomplete
+
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ install_build_auto_skill(project.project)
+ _real_skill_dirs(project, *install.REVIEW_HUNTER_SKILLS)
+ _symlink_skill_subdir(
+ project,
+ tmp_path / "shared-bmad-install",
+ install.DEV_PRIMITIVE_NEW,
+ "review-prompts",
+ "adversarial.md",
+ "edge-case.md",
+ )
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ assert install.missing_base_skills(project.project, [".claude/skills"]) == []
+
+ def never(spec):
+ raise AssertionError("dispatched into a worktree missing the primitive's review prompts")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ task = engine.state.tasks["1-1-a"]
+ escalated = [e for e in Journal(engine.run_dir).entries() if e["kind"] == "story-escalated"]
+ assert escalated
+ # both children of the symlinked dir, each as its own nested rel
+ assert base_skills_seed_incomplete(
+ Path(task.worktree_path), project.project, [".claude/skills"]
+ ) == [
+ ".claude/skills/bmad-build-auto/review-prompts/adversarial.md",
+ ".claude/skills/bmad-build-auto/review-prompts/edge-case.md",
+ ]
+ # the rest of the dir arrived: the sub-directory is the only casualty
+ primitive = Path(task.worktree_path) / ".claude" / "skills" / "bmad-build-auto"
+ assert (primitive / "SKILL.md").is_file()
+ assert (primitive / "customize.toml").is_file()
+ assert not (primitive / "review-prompts").exists()
+
+
+def test_a_partially_tracked_skill_dir_is_repaired_not_escalated(project):
+ """The clearing leg for the granularity fix, and the proof it needs BOTH layers:
+ half of it — the per-file gate without the per-file merge — would pause a worktree
+ that provisioning is perfectly able to repair.
+
+ No symlinks anywhere (so this runs on Windows too). `.claude/` is deliberately NOT
+ gitignored and `customize.toml` is deliberately NOT committed, which is the
+ ordinary shape of a project that tracks its skill tree but keeps one generated or
+ machine-local layer out of git. The worktree checks out every tracked file of
+ `bmad-build-auto` and none of that one — a destination directory that EXISTS while
+ being short, i.e. exactly what the old dir-level `if dst.exists(): continue` skipped
+ whole. Per-FILE merge fills the hole, the gate finds nothing missing, and the run
+ completes.
+
+ Second half: the file provisioning just wrote is untracked and un-gitignored, so
+ the unit's `git add -A` would sweep it into the story commit — the worktree's local
+ git exclude is the only thing stopping a tool file the repo deliberately does not
+ track from being committed back to the target branch."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ from bmad_loop import install
+
+ # `.claude/` NOT ignored: the skill dir has to be TRACKABLE for the checkout to
+ # carry half of it, which is the whole premise
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n", encoding="utf-8")
+ skill = install_build_auto_skill(project.project, ".claude/skills")
+ _real_skill_dirs(project, *install.REVIEW_HUNTER_SKILLS)
+ (skill / "customize.toml").unlink()
+ commit_sprint(project, {"1-1-a": "ready-for-dev"}) # tracks everything BUT that file
+ # ...and back it comes: present in the repo, untracked, not gitignored
+ (skill / "customize.toml").write_text(
+ '[[review_layers]]\nid = "adversarial"\nprompt_file = "review-prompts/adversarial.md"\n',
+ encoding="utf-8",
+ )
+ # the fixture's own premise, asserted rather than assumed: a tracked file here
+ # would be checked out and there would be nothing to repair
+ assert ".claude/skills/bmad-build-auto/customize.toml" in git(
+ project.project, "status", "--porcelain"
+ )
+ # the repo passes its preflight, so a pause below could only be provisioning's
+ assert install.missing_base_skills(project.project, [".claude/skills"]) == []
+
+ head_before = rev_parse_head(project.project)
+ engine, adapter = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ )
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert not summary.paused and summary.escalated == 0
+ assert engine.state.tasks["1-1-a"].phase == Phase.DONE
+ assert "story-escalated" not in [e["kind"] for e in Journal(engine.run_dir).entries()]
+ # ...and the repair stayed in the worktree: every commit the run landed carries the
+ # code change and not one path under the skill tree
+ files = git(
+ project.project, "log", "--pretty=format:", "--name-only", f"{head_before}..HEAD"
+ ).splitlines()
+ assert "src.txt" in files
+ assert not [f for f in files if f.startswith(".claude/skills")]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_central_config_seed_pauses_before_dispatch(project, tmp_path):
+ """The other leg of the same gate, and the one nothing above can reach: here
+ `_bmad/scripts/` is a real directory that seeds WHOLE, and only
+ `_bmad/config.toml` is symlinked out of the repo. The scripts check is therefore
+ silent, `_seed_bmad_tree` reports the whole tree as seeded, and the repo-side
+ preflight follows the symlink and passes — so before this leg existed the run
+ dispatched into a worktree whose every session HALTs in `load_central_config`.
+
+ The reason must name the CONFIG and not the scripts dir: the two have different
+ remediations, and sending an operator to `_bmad/scripts` when it is intact is
+ exactly the drift the shared sentinel tuple exists to prevent."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=True)
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ shared = tmp_path / "shared-bmad"
+ shared.mkdir()
+ (shared / "config.toml").write_text("[core]\n", encoding="utf-8")
+ bmad = project.project / "_bmad"
+ (bmad / "scripts").mkdir(parents=True)
+ (bmad / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (bmad / "scripts" / "config_utils.py").write_text("# config", encoding="utf-8")
+ (bmad / "config.toml").symlink_to(shared / "config.toml")
+ # the repo-side probe follows the symlink, which is why the preflight passed
+ assert (bmad / "config.toml").is_file()
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree with no central config")
+
+ engine, adapter = make_engine(project, [never, never])
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ task = engine.state.tasks["1-1-a"]
+ assert task.phase == Phase.ESCALATED
+ assert "1-1-b" not in engine.state.tasks
+ entries = Journal(engine.run_dir).entries()
+ escalated = [e for e in entries if e["kind"] == "story-escalated"]
+ assert escalated
+ assert CENTRAL_CONFIG_REL in escalated[0]["reason"]
+ assert BMAD_SCRIPTS_SEED_REL not in escalated[0]["reason"]
+ # the worktree stays mounted, and it is the CONFIG half that is short
+ assert not (Path(task.worktree_path) / "_bmad" / "config.toml").exists()
+ assert (Path(task.worktree_path) / "_bmad" / "scripts" / "render_skill.py").is_file()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_short_scripts_seed_does_not_pause_an_inline_primitive(project, tmp_path):
+ """The ERA discriminator for the gate above. Same symlinked `_bmad/scripts/`,
+ same dropped files, same `worktree-seed-skipped` report — the single delta is
+ that the resolved dev primitive is a pre-#2601 INLINE SKILL.md.
+
+ Nothing in such a project ever shells out to `render_skill.py`, so a short
+ `_bmad/scripts/` seed costs the run nothing and the whole backlog must run to
+ completion. `_bmad_scripts_seed_incomplete` cannot tell the two apart — it only
+ ever sees the REPO, which carries a renderer-era `_bmad/scripts/` in both — so
+ this is the leg that pins the discrimination onto the engine's gate.
+
+ Fails three independent ways on the un-gated build (`if BMAD_SCRIPTS_SEED_REL in
+ skipped_seeds` alone): the run pauses, story 1 escalates, and story 2 is never
+ dispatched."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ # the ONLY delta from the sibling above (which installs the same skill with
+ # renderer_stub=True): an inline SKILL.md, no render_skill.py reference in it
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=False)
+ commit_sprint(project, {"1-1-a": "ready-for-dev", "1-1-b": "ready-for-dev"})
+ shared = tmp_path / "shared-bmad-scripts"
+ shared.mkdir()
+ (shared / "render_skill.py").write_text("# render", encoding="utf-8")
+ (shared / "config_utils.py").write_text("# config", encoding="utf-8")
+ bmad = project.project / "_bmad"
+ bmad.mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts").symlink_to(shared, target_is_directory=True)
+ # the repo-side half of the trigger is armed exactly as in the sibling: the
+ # probe sees the renderer through the symlink, and the seed cannot follow it
+ assert (bmad / "scripts" / "render_skill.py").is_file()
+ assert not renderer_stub_resolved(project.project, [".claude/skills"])
+
+ # what the worktree the session is actually handed looked like — the run
+ # completing is only meaningful if it completed over a SHORT seed
+ seen: list[tuple[bool, bool]] = []
+ dev_a = wt_dev_effect(project, "1-1-a")
+
+ def dev_and_probe(spec):
+ wt_bmad = spec.cwd / "_bmad"
+ seen.append(
+ (
+ (wt_bmad / "config.toml").is_file(),
+ (wt_bmad / "scripts" / "render_skill.py").is_file(),
+ )
+ )
+ return dev_a(spec)
+
+ engine, adapter = make_engine(
+ project,
+ [
+ dev_and_probe,
+ wt_review_effect(project, "1-1-a", clean=True),
+ wt_dev_effect(project, "1-1-b"),
+ wt_review_effect(project, "1-1-b", clean=True),
+ ],
+ )
+ attach_profile(adapter) # one call arms dev AND review: they share the adapter
+ summary = engine.run()
+
+ # not just story 1: the whole backlog, which is what the escalation would cost
+ assert summary.done == 2 and not summary.paused
+ entries = Journal(engine.run_dir).entries()
+ kinds = [e["kind"] for e in entries]
+ assert "story-escalated" not in kinds
+ # ...and the report itself is byte-identical to the escalating sibling's.
+ # provision_worktree stayed a pure reporter, so the sentinel really did ride
+ # the channel — the engine's gate is what declined to act on it, which is the
+ # only way this can be a discrimination rather than a silenced signal.
+ skipped = [e for e in entries if e["kind"] == "worktree-seed-skipped"]
+ assert skipped and any(BMAD_SCRIPTS_SEED_REL in e["entries"] for e in skipped)
+ # seeding ran (config.toml made it in) and the renderer half really was short,
+ # so the run completed over exactly the environment the sibling pauses on
+ assert seen == [(True, False)]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_stub_in_the_review_tree_alone_still_pauses(project, tmp_path):
+ """A run can mix skill trees (dev=claude → .claude/skills, review=gemini →
+ .agents/skills) and the two can sit on different upstream eras. The gate asks
+ ANY over both, because either session kind reaching a renderer that is not
+ there is one result-less Stop per story just the same.
+
+ Here the DEV tree is inline and only the REVIEW tree is a #2601 stub. It is the
+ one ablation that catches implementing the gate against `self._dev_skill()` or
+ the dev adapter's tree alone: every other test in this family passes under that
+ bug, because in every other one the dev tree answers for both."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=False)
+ install_build_auto_skill(project.project, ".agents/skills", renderer_stub=True)
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ shared = tmp_path / "shared-bmad-scripts"
+ shared.mkdir()
+ (shared / "render_skill.py").write_text("# render", encoding="utf-8")
+ (shared / "config_utils.py").write_text("# config", encoding="utf-8")
+ bmad = project.project / "_bmad"
+ bmad.mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts").symlink_to(shared, target_is_directory=True)
+ # the premise, asserted rather than assumed: two trees, two eras. Without this
+ # the test would still pass with BOTH trees stubbed, proving nothing per-role.
+ assert not renderer_stub_resolved(project.project, [".claude/skills"])
+ assert renderer_stub_resolved(project.project, [".agents/skills"])
+
+ dispatched: list[str] = []
+
+ def never(spec):
+ dispatched.append(spec.cwd.name)
+ raise AssertionError("dispatched into a worktree with no renderer")
+
+ engine, dev = make_engine(
+ project, [never], review_adapter=attach_profile(MockAdapter([never]), "gemini")
+ )
+ attach_profile(dev, "claude")
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ assert dispatched == []
+ assert engine.state.tasks["1-1-a"].phase == Phase.ESCALATED
+ assert "story-escalated" in journal_kinds(engine)
+
+
+def test_a_benign_skipped_seed_does_not_pause(project):
+ """The clearing leg for the gate above, and it has to arm a NON-EMPTY
+ `skipped_seeds` to earn its keep: a `worktree_seed` dir the checkout already
+ carries is reported skipped, which is the ordinary informational case that has
+ always ridden this channel. Escalating on it would pause every run that has a
+ no-op seed entry.
+
+ Ablation this catches and a bare happy-path cannot: widening the gate from the
+ `_bmad/scripts` sentinel to `if skipped_seeds:`."""
+ bmad = project.project / "_bmad"
+ (bmad / "scripts").mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (bmad / "scripts" / "config_utils.py").write_text("# config", encoding="utf-8")
+ # tracked, so the worktree checks it out and copy-when-absent makes the seed a
+ # no-op — provision_worktree reports it, and it must stay informational
+ vendor = project.project / "vendor"
+ vendor.mkdir()
+ (vendor / "conf.txt").write_text("x\n", encoding="utf-8")
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+
+ engine, _ = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ policy=wt_policy(worktree_seed=("vendor",)),
+ )
+ summary = engine.run()
+
+ assert summary.done == 1 and not summary.paused
+ kinds = journal_kinds(engine)
+ assert "worktree-seed-skipped" in kinds # the channel really did fire
+ assert "story-escalated" not in kinds
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_seed_is_journalled_and_does_not_pause(project, tmp_path):
+ """The OTHER half of the seed silence (#415): an entry the repo HAS that the
+ worktree did not get. The repo carries `.mcp.json` as a symlink out of itself —
+ dotfile-managed, an ordinary healthy setup — so the resolve-and-contain guard
+ drops it and the session runs without it.
+
+ Journaled, never escalated, and that is the deliberate difference from the two
+ gates above: those name files the ORCHESTRATOR dispatches or the renderer HALTs
+ on, so their absence stalls every story determinately. A seed entry is arbitrary
+ user config bmad-loop cannot know the session needs, and the trigger is a setup
+ that works — escalating would refuse every run of such a project over a guard
+ doing its job.
+
+ Its own kind, not `worktree-seed-skipped`: "already there, your entry is doing
+ nothing" and "the repo has it and this worktree does not" want different fixes,
+ and the skipped channel additionally carries reserved sentinels the renderer
+ escalation reads by exact membership."""
+ # gitignored, which is what makes it a seed entry at all: a worktree checks out
+ # tracked files only, so the fresh checkout has none of it
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.mcp.json\n", encoding="utf-8")
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ shared = tmp_path / "dotfiles" / "mcp.json"
+ shared.parent.mkdir(parents=True)
+ shared.write_text('{"mcpServers": {}}', encoding="utf-8")
+ (project.project / ".mcp.json").symlink_to(shared)
+
+ engine, _ = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ policy=wt_policy(worktree_seed=(".mcp.json",)),
+ )
+ summary = engine.run()
+
+ assert summary.done == 1 and not summary.paused
+ entries = Journal(engine.run_dir).entries()
+ kinds = [e["kind"] for e in entries]
+ assert "story-escalated" not in kinds
+ dropped = [e for e in entries if e["kind"] == "worktree-seed-dropped"]
+ assert dropped and dropped[0]["entries"] == [".mcp.json"]
+ # the no-op channel stayed quiet: this entry is not doing nothing, it is missing
+ assert "worktree-seed-skipped" not in kinds
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_a_dropped_hook_config_is_journalled_and_does_not_pause(project, tmp_path):
+ """The one seed rel whose destination cannot answer for it (#405), and this is the
+ ENGINE's witness that the call site says so.
+
+ `.claude/settings.json` is claude's `hooks.config_path` as well as one of its
+ default seed entries, and here the repo carries it as a symlink to a dotfile
+ checkout — the same ordinary healthy setup the sibling above uses for `.mcp.json`,
+ and the trigger the gate's own docstring names. The seed loop's source-containment
+ guard drops it; the hook-registration step below provisioning then writes that exact
+ path with a hooks-only config, so the destination probe stats a real file and reads
+ the rel as delivered. Nothing is short at a path anyone can see, and the operator's
+ own `env` block never reaches the session.
+
+ At the engine and not only in `test_install.py` because passing the rels is the
+ CALL SITE's half: `config_paths` defaults to `()`, so a fix that never reaches this
+ call is byte-identical to no fix and invisible to every unit test of the gate.
+
+ `attach_profile` is what makes the fault expressible at all, and both halves of it
+ are the profile's: with no `profile` on the adapter `_worktree_profiles()` is empty,
+ so `.claude/settings.json` is not a seed entry AND the hook step never writes it.
+ The near-miss shape to avoid is naming the rel in `worktree_seed` instead — a
+ profile-less run then leaves the destination genuinely absent, the ordinary probe
+ reports it, and the test passes on the unfixed build."""
+ from conftest import attach_profile
+
+ from bmad_loop import install
+
+ # gitignored `.claude/` is the shape that bites, and the reason the settings file
+ # is a seed entry at all: a worktree checks out tracked files only
+ (project.project / ".gitignore").write_text(".bmad-loop/runs/\n.claude/\n", encoding="utf-8")
+ # ordinary in-repo skills, so the skills gate stays silent and the escalation
+ # assertion below can only be about this fix
+ _real_skill_dirs(
+ project, install.DEV_PRIMITIVE_NEW, *install.REVIEW_HUNTER_SKILLS, "bmad-review"
+ )
+ shared = tmp_path / "dotfiles" / "settings.json"
+ shared.parent.mkdir(parents=True)
+ shared.write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ (project.project / ".claude" / "settings.json").symlink_to(shared)
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+
+ engine, adapter = make_engine(
+ project,
+ [wt_dev_effect(project, "1-1-a"), wt_review_effect(project, "1-1-a", clean=True)],
+ )
+ attach_profile(adapter) # without this the hook step never runs — see the docstring
+ summary = engine.run()
+
+ assert summary.done == 1 and not summary.paused
+ entries = Journal(engine.run_dir).entries()
+ assert "story-escalated" not in [e["kind"] for e in entries]
+ dropped = [e for e in entries if e["kind"] == "worktree-seed-dropped"]
+ assert dropped and dropped[0]["entries"] == [".claude/settings.json"]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
+def test_an_escalating_provision_still_journals_the_worktree_it_mounted(project, tmp_path):
+ """Both provisioning escalations promise the half-provisioned worktree "stays
+ mounted for the operator to inspect", and the journal was the one place that never
+ said WHERE — `worktree-opened` used to be appended after every gate had passed, so
+ the runs that most need the path were exactly the ones with no record of it.
+
+ Appended beside the state it mirrors instead, as soon as `task.worktree_path` is
+ set, so it pairs 1:1 with `worktree-open-failed` and every mount attempt leaves
+ exactly one record either way. The renderer leg is the witness because it is the
+ LAST thing below the append that can raise; the fixture is
+ `test_incomplete_bmad_scripts_seed_pauses_before_dispatch`'s."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills", renderer_stub=True)
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ shared = tmp_path / "shared-bmad-scripts"
+ shared.mkdir()
+ (shared / "render_skill.py").write_text("# render", encoding="utf-8")
+ (shared / "config_utils.py").write_text("# config", encoding="utf-8")
+ bmad = project.project / "_bmad"
+ bmad.mkdir(parents=True)
+ (bmad / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (bmad / "scripts").symlink_to(shared, target_is_directory=True)
+
+ engine, adapter = make_engine(project, []) # empty script: nothing may be dispatched
+ attach_profile(adapter)
+ summary = engine.run()
+
+ assert summary.paused and summary.escalated == 1
+ entries = Journal(engine.run_dir).entries()
+ kinds = [e["kind"] for e in entries]
+ opened = [e for e in entries if e["kind"] == "worktree-opened"]
+ assert opened and kinds.index("worktree-opened") < kinds.index("story-escalated")
+ # …and it names the worktree the escalation leaves mounted, not just the attempt
+ assert opened[0]["path"] == engine.state.tasks["1-1-a"].worktree_path
+ assert Path(opened[0]["path"]).is_dir()
+
+
+def test_harvest_reverted_on_retry_under_isolation(project):
+ """#405, workspace-scoped: the pre-harvest snapshot and its restore read the
+ UNIT WORKTREE's ledger — the same tree `_rollback_or_pause` resets — so a
+ harvested finding about work the rollback discarded never reaches the unit's
+ merge. Isolation is not assumed to follow from the in-place case: the ledger
+ path comes from `workspace.paths`, which differs between them."""
+ commit_sprint(project, {"1-1-a": "ready-for-dev"})
+ finding = {
+ "summary": "Retry loop has no ceiling",
+ "evidence": "the backoff doubles forever: no cap",
+ "location": "src/retry.py:88",
+ "severity": "medium",
+ }
+
+ def deferring_liar(spec):
+ # completes and finalizes (the harvest fires) but claims a foreign
+ # baseline — the non-fixable retry that routes through _rollback_or_pause
+ cwd = spec.cwd
+ wt = project.rebased(cwd)
+ src = cwd / "src.txt"
+ src.write_text(src.read_text() + "bad attempt\n")
+ sp = wt.implementation_artifacts / "spec-1-1-a.md"
+ write_spec(sp, "done", "0" * 40, deferred=[finding])
+ return SessionResult(
+ status="completed",
+ result_json={
+ "workflow": "auto-dev",
+ "story_key": "1-1-a",
+ "spec_file": str(sp),
+ "baseline_commit": "0" * 40,
+ "escalations": [],
+ "followup_review_recommended": False,
+ },
+ )
+
+ seen: list[bool] = []
+
+ def probing_dev(spec):
+ # attempt 2 opens on the reverted worktree: the harvest's ledger is gone
+ seen.append(project.rebased(spec.cwd).deferred_work.exists())
+ return wt_dev_effect(project, "1-1-a", followup_review=False)(spec)
+
+ head_before = rev_parse_head(project.project)
+ engine, _ = make_engine(
+ project,
+ [deferring_liar, probing_dev],
+ policy=wt_policy(limits=LimitsPolicy(max_dev_attempts=2)),
+ )
+ summary = engine.run()
+
+ assert summary.done == 1
+ kinds = journal_kinds(engine)
+ assert "rollback-auto" in kinds
+ harvests = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvests) == 1 and harvests[0]["dw_ids"] == ["DW-1"]
+ assert seen == [False]
+ files = git(
+ project.project, "log", "--pretty=format:", "--name-only", f"{head_before}..HEAD"
+ ).split()
+ assert "src.txt" in files
+ assert not [f for f in files if f.endswith("deferred-work.md")]
+ assert not project.deferred_work.exists()
+
+
# ----------------------------------------------------------------- new guards (review hardening)
diff --git a/tests/test_install.py b/tests/test_install.py
index 686e2e56..aa2a319c 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -1,4 +1,5 @@
import json
+import shutil
import sys
import zipfile
@@ -9,13 +10,18 @@
from bmad_loop.adapters.profile import get_profile
from bmad_loop.install import (
BASE_SKILLS,
+ DEV_BASE_SKILLS,
+ DEV_PRIMITIVE_LEGACY,
+ DEV_PRIMITIVE_NEW,
LEGACY_MODULE_SKILLS,
MODULE_SKILLS,
_copy_traversable,
+ base_skills_seed_incomplete,
install_into,
merge_hooks,
missing_base_skills,
provision_worktree,
+ resolve_dev_primitive,
strip_legacy_hooks,
)
@@ -416,6 +422,9 @@ def test_install_into_full(tmp_path):
assert ".bmad-loop/runs/" in gitignore
assert ".bmad-loop/cache/" in gitignore # engine plugins' rebuildable caches
assert ".bmad-loop/policy.toml" in gitignore # per-machine config ([mux] backend)
+ # the renderer's output: machine-absolute, rewritten every session. Under
+ # isolation = "none" (the default) this line is its only protection.
+ assert "_bmad/render/" in gitignore
# all bundled skills land in claude's tree, with nested files intact
skills_dir = tmp_path / ".claude" / "skills"
@@ -431,6 +440,7 @@ def test_install_into_full(tmp_path):
assert final_gitignore.count(".bmad-loop/runs/") == 1
assert final_gitignore.count(".bmad-loop/cache/") == 1
assert final_gitignore.count(".bmad-loop/policy.toml") == 1
+ assert final_gitignore.count("_bmad/render/") == 1
def test_install_into_warns_when_policy_is_tracked(tmp_path, capsys):
@@ -565,8 +575,12 @@ def test_provision_worktree_covers_multiple_profiles(tmp_path):
def test_provision_worktree_does_not_clobber_existing_skill(tmp_path):
- """A skill the checkout already carries (project commits its own skill tree)
- is left untouched, so no diff is merged back."""
+ """A FILE the checkout already carries (project commits its own skill tree) is
+ left untouched, so no diff is merged back. Copy-when-absent is asked per file,
+ not per directory: the dir-level skip it replaces was silent and TOTAL — one
+ tracked SKILL.md made the whole dir "already there" and every sibling the skill
+ reads stayed at zero bytes, in a dir that then passes a directory-granular
+ completeness check."""
wt, repo = tmp_path / "wt", tmp_path / "repo"
claude = get_profile("claude")
existing = wt / claude.skill_tree / "bmad-loop-sweep" / "SKILL.md"
@@ -574,7 +588,9 @@ def test_provision_worktree_does_not_clobber_existing_skill(tmp_path):
existing.write_text("COMMITTED", encoding="utf-8")
provision_worktree(wt, [claude], repo)
- assert existing.read_text() == "COMMITTED"
+ assert existing.read_text(encoding="utf-8") == "COMMITTED"
+ # …while the siblings of that one carried file are filled in
+ assert (wt / claude.skill_tree / "bmad-loop-sweep" / "deferred-work-format.md").is_file()
# a skill that was absent is still laid down
assert (wt / claude.skill_tree / "bmad-loop-resolve" / "SKILL.md").is_file()
@@ -599,10 +615,918 @@ def test_provision_worktree_copies_base_skills_from_repo(tmp_path):
assert (wt / claude.skill_tree / "bmad-dev-auto" / "step-04-review.md").is_file()
+def test_provision_worktree_fills_in_a_partial_base_skill(tmp_path):
+ """A checkout that carries ONE file of a skill dir — a project that commits its
+ SKILL.md, a `worktree_seed` entry naming a file inside one — used to make the whole
+ dir "already there" and receive zero bytes of the rest. That skips silently and
+ totally: the session dispatches a primitive whose step files and review-prompts/
+ are absent, and provisioning reports success. Filling the absent siblings in is
+ what the per-FILE merge buys, and the empty return is the other half — a dir the
+ merge completed is not a skip to journal."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ carried = wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md"
+ carried.parent.mkdir(parents=True)
+ carried.write_text("# bmad-build-auto\n", encoding="utf-8")
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ skill = wt / tree / DEV_PRIMITIVE_NEW
+ assert (skill / "step-04-review.md").is_file()
+ assert (skill / "customize.toml").is_file()
+ assert (skill / "review-prompts" / "adversarial.md").is_file()
+ assert skipped == []
+
+
+def test_provision_worktree_merge_keeps_the_bytes_the_checkout_carries(tmp_path):
+ """The bound on the fill-in above: per-file copy-when-ABSENT must not drift into
+ copy-over. A project that customizes a skill in-tree has those bytes tracked, and
+ overwriting them puts the upstream copy into the unit's `git add -A` commit — the
+ diff isolation exists to prevent."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ carried = wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md"
+ carried.parent.mkdir(parents=True)
+ carried.write_text("COMMITTED", encoding="utf-8")
+
+ provision_worktree(wt, [claude], repo)
+
+ assert carried.read_text(encoding="utf-8") == "COMMITTED"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_reports_a_symlinked_out_skill_child(tmp_path):
+ """Per-file granularity splits the containment guard per child, so the failure it
+ guards against is no longer whole-or-nothing: a skill dir whose customize.toml
+ alone points at a shared install outside the repo lands SHORT while every sibling
+ copies fine. The repo-side preflight stats through the link and passes, so nothing
+ upstream says a word — without a per-file report the run dispatches a step-04 whose
+ layer config was never delivered, on every story."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "customize.toml").write_text("[[review_layers]]\n", encoding="utf-8")
+ marker = repo / tree / DEV_PRIMITIVE_NEW / "customize.toml"
+ marker.unlink()
+ marker.symlink_to(shared / "customize.toml")
+ assert marker.is_file() # the preflight follows the link, which is why it passed
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}/customize.toml"]
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+ assert not (wt / tree / DEV_PRIMITIVE_NEW / "customize.toml").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_merge_refuses_to_write_outside_the_worktree(tmp_path):
+ """The destination half of the containment guard, which only the merge needs: the
+ dir-level skip never reached a per-file destination at all. A symlink in the
+ CHECKOUT pointing out of the worktree is a path `_copy_traversable` would follow
+ and write THROUGH, landing the wheel's bytes somewhere no teardown ever cleans.
+ Refusing leaves the worktree without a readable SKILL.md, which the gate then
+ reports coarsely — the whole dir is what a session cannot dispatch."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ escape = wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md"
+ escape.parent.mkdir(parents=True)
+ escape.symlink_to(outside / "escaped.md") # dangling on purpose
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert not (outside / "escaped.md").exists()
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_merge_refuses_a_symlinked_out_skill_tree(tmp_path):
+ """The containment clause of that same guard, on its own — and until this test no
+ row in the suite moved when it was deleted (#405).
+
+ The sibling above arms `_occupied` as well, because its escape is a DANGLING leaf
+ and a dangling symlink is occupied by definition; `or _occupied(target)` answers
+ first and the containment clause never has to. Here the link is the skill TREE and
+ it is LIVE, so every target beneath it is absent and unoccupied and containment is
+ the only thing left between the wheel's bundled skills and a directory outside the
+ worktree that no teardown ever cleans.
+
+ `skipped` stays empty on purpose: this repo carries no `BASE_SKILLS` for
+ `base_skills_seed_incomplete` to find short, so the filesystem is the whole
+ assertion — which is exactly the shape that makes an unguarded write invisible."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ claude = get_profile("claude")
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ (wt / claude.skill_tree).parent.mkdir(parents=True)
+ (wt / claude.skill_tree).symlink_to(outside, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert list(outside.iterdir()) == [] # the wheel's skills went nowhere
+ assert skipped == []
+
+
+# --------------------------------------------------------------- the shared walk itself
+#
+# ⚠️ Every test from here to `_seed_pair` needs a symlink or a permission bit to arm,
+# so NONE of them can have a Windows witness — the conjuncts they pin are covered by
+# the POSIX lanes alone. The designated symlink-free Windows witnesses for this file
+# are `test_a_dropped_nested_file_is_reported_with_a_posix_rel`,
+# `test_a_nested_snapshot_target_is_keyed_by_its_posix_rel` and
+# `test_provision_worktree_central_config_check_asks_is_file_not_exists`; they stay
+# unguarded on purpose and none of the fixtures below may be pushed into them.
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_the_walk_does_not_raise_on_an_undescendable_directory(tmp_path):
+ """The fail-soft `iterdir`, asked only for the half that must never regress: the
+ walk RETURNS on a directory it cannot list.
+
+ Raising instead takes an unreadable repo skill out through `Engine._run_isolated`,
+ which has no `try` around provisioning: measured, the run ends with `crash.txt` and
+ `state.crashed`, leaving a worktree whose Stop hook was never registered — where
+ the same fault, degraded, is one story's escalation naming one rel.
+
+ Split from the sibling test on purpose, because the two ablations are different
+ edits: deleting the `try` reddens both, while swallowing the entry (`except OSError:
+ return`) reddens only the sibling. POSIX-only, non-root runner (root reads through
+ mode 000)."""
+ from bmad_loop.install import _walk_traversable_files
+
+ root = tmp_path / "tree"
+ (root / "locked").mkdir(parents=True)
+ (root / "locked" / "deep.md").write_text("# deep\n", encoding="utf-8")
+ (root / "a.md").write_text("# a\n", encoding="utf-8")
+ (root / "locked").chmod(0o000)
+ try:
+ rels = [rel for rel, _ in _walk_traversable_files(root)]
+ finally:
+ (root / "locked").chmod(0o755)
+
+ assert "a.md" in rels
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_an_undescendable_directory_is_yielded_as_a_leaf(tmp_path):
+ """The other half: it is not merely survived, it is NAMED.
+
+ Swallowing it — `except OSError: return` — is the tempting one-word variant and it
+ is the failure this whole cluster exists to prevent: the copier delivers nothing
+ from that subtree and the gate, walking the same yield, has nothing to report, so
+ an unreadable skill dir reads as a healthy one. Yielding the directory as a leaf is
+ what lets the two halves split on one predicate — the copier's `_is_file` drops it
+ as undeliverable, the gates' `_is_file or _is_dir` names it as missing.
+
+ The rel names a DIRECTORY, which is the honest granularity: nobody can say which
+ files are short when the directory cannot be listed. POSIX-only, non-root runner."""
+ from bmad_loop.install import _walk_traversable_files
+
+ root = tmp_path / "tree"
+ (root / "locked").mkdir(parents=True)
+ (root / "locked" / "deep.md").write_text("# deep\n", encoding="utf-8")
+ (root / "a.md").write_text("# a\n", encoding="utf-8")
+ (root / "locked").chmod(0o000)
+ try:
+ walked = dict(_walk_traversable_files(root))
+ finally:
+ (root / "locked").chmod(0o755)
+
+ assert sorted(walked) == ["a.md", "locked"]
+ assert walked["locked"].is_dir() # a directory, handed back as a leaf
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_the_walk_names_the_children_of_an_unsearchable_directory(tmp_path):
+ """The OTHER unreadable-directory mode, and the one that hid a crash: readable but
+ NOT searchable (`0o444`, or `chmod -R a-x`).
+
+ `iterdir()` needs `r` and succeeds, so the walk gets the child NAMES; stat-ing a
+ child needs `x` on the directory and is refused, so `is_dir()` raises on every one
+ of them. That is a different fault from the sibling above, where `iterdir()` itself
+ fails and the directory is yielded whole — and it went unseen because every
+ unreadable-directory fixture in this file used `0o000`. Measured before `_is_dir`:
+ `PermissionError` out of `provision_worktree` on 3.11/3.12/3.13, while 3.14 answered
+ False and dropped the subtree in silence.
+
+ The children are named INDIVIDUALLY rather than the directory being named once,
+ which is the honest granularity in the other direction from the sibling: `iterdir`
+ did give up the names, so a rel per name is exactly what is known. POSIX-only,
+ non-root runner."""
+ from bmad_loop.install import _walk_traversable_files
+
+ root = tmp_path / "tree"
+ (root / "listable").mkdir(parents=True)
+ (root / "listable" / "deep.md").write_text("# deep\n", encoding="utf-8")
+ (root / "a.md").write_text("# a\n", encoding="utf-8")
+ (root / "listable").chmod(0o444)
+ try:
+ rels = sorted(rel for rel, _ in _walk_traversable_files(root))
+ finally:
+ (root / "listable").chmod(0o755)
+
+ assert rels == ["a.md", "listable/deep.md"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_is_dir_reads_a_refused_probe_as_a_directory_on_every_interpreter(tmp_path, monkeypatch):
+ """The version split `_is_dir` has to close, pinned WITHOUT depending on which
+ version is running it.
+
+ The witnesses either side of this one exercise only the reading the LOCAL
+ interpreter produces: through 3.13 `is_dir()` raises and `_is_dir`'s `except`
+ answers, while on 3.14 it returns False and `_probe_refused` is the only thing
+ between an unreadable subtree and a silent drop. Measured with the `except` alone,
+ both `0o444` witnesses passed on 3.11/3.12/3.13 and FAILED on 3.14 — a green local
+ suite and a red CI lane, which is exactly the shape a local-only ablation cannot
+ see. Patching `is_dir` to answer False emulates 3.14's reading on any lane, so both
+ branches are covered wherever this runs. `stat()` is untouched by the patch, and
+ measured on all four it still raises `EACCES` here.
+
+ The ABSENT case is what keeps the loud fallback honest: a path that simply is not
+ there must stay False, or every leaf the walk yields would be re-descended and
+ named missing. `EACCES` is refusal; `ENOENT` is absence."""
+ from pathlib import Path
+
+ from bmad_loop.install import _is_dir, _probe_refused
+
+ child = tmp_path / "unsearchable" / "child.md"
+ child.parent.mkdir()
+ child.write_text("x\n", encoding="utf-8")
+ child.parent.chmod(0o444)
+ try:
+ assert _is_dir(child) is True # this lane's own reading, whichever it is
+ monkeypatch.setattr(Path, "is_dir", lambda self, **kw: False) # the 3.14 one
+ assert _is_dir(child) is True # ... and it survives that reading too
+ assert _is_dir(tmp_path / "nope") is False # absent stays absent under it
+ finally:
+ child.parent.chmod(0o755)
+
+ # A wheel Traversable has no `stat` and no permission fault to find; re-asking is
+ # scoped to real Paths, and anything else keeps whatever `is_dir()` said.
+ assert _probe_refused(object()) is False
+
+ # An invalid path is the one fault where `stat()` is LESS total than `is_dir()`:
+ # measured on 3.11 and 3.14, `is_dir()` answers False and `stat()` raises
+ # `ValueError`. Re-asking without catching it would add a raise to the very
+ # function whose contract is that provisioning has none.
+ assert _is_dir(Path("a\0b")) is False
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_the_walk_terminates_on_a_symlink_cycle(tmp_path):
+ """`rglob` needs no cycle guard because it refuses to descend child symlinks at
+ all. Descending them is this walk's whole reason to exist, and it is what makes
+ `_bmad/scripts/loop -> ..` unbounded.
+
+ Measured before the guard, on this very fixture: 42 files, every one passing the
+ resolve-and-contain check because `resolve()` collapses the loop back inside the
+ repo — so they were COPIED, not skipped — terminating only by accident when the
+ kernel's `ELOOP` finally made `is_dir()` answer False. Where that lands is not a
+ property of the fixture: the limit applies to the ABSOLUTE path, so re-measuring it
+ from a shallower tmpdir moves the depth (82 components against the 85 first seen)
+ while the file count holds. A hostile tree is not the
+ scenario; a shared BMAD install wired with a convenience symlink is."""
+ from bmad_loop.install import _walk_traversable_files
+
+ root = tmp_path / "tree"
+ (root / "scripts").mkdir(parents=True)
+ (root / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (root / "scripts" / "loop").symlink_to(root, target_is_directory=True)
+
+ assert [rel for rel, _ in _walk_traversable_files(root)] == ["scripts/render_skill.py"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_two_sibling_symlinks_to_one_install_are_not_a_cycle(tmp_path):
+ """The cycle guard is BRANCH-local, and this is the rejected design it is measured
+ against: one global `seen` set terminates the loop above just as well and silently
+ drops the second sibling's rels from the copy AND the gate.
+
+ Two skill subdirectories pointed at one shared install is not a cycle — it is how a
+ shared install gets wired — and each of them has to be seeded on its own account.
+ A global set would make the ablation of this test the *quiet* kind: files missing
+ from the worktree with nothing reporting them."""
+ from bmad_loop.install import _walk_traversable_files
+
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "tool.py").write_text("# tool", encoding="utf-8")
+ root = tmp_path / "tree"
+ root.mkdir()
+ (root / "a_lib").symlink_to(shared, target_is_directory=True)
+ (root / "b_lib").symlink_to(shared, target_is_directory=True)
+
+ assert [rel for rel, _ in _walk_traversable_files(root)] == [
+ "a_lib/tool.py",
+ "b_lib/tool.py",
+ ]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_never_writes_through_a_dangling_dst_symlink(tmp_path):
+ """A committed symlink whose target does not exist on this machine — a thing git
+ stores and checks out happily — occupies the slot, and `exists()` says it does not.
+
+ Measured: writing anyway lands the bytes at the LINK'S TARGET, not at the slot, so
+ the unit's `git add -A` picks up an untracked file the session never asked for while
+ the completeness gate reads green through the now-live link. Distinct from
+ `test_provision_worktree_merge_refuses_to_write_outside_the_worktree`, whose link
+ escapes the worktree and dies on containment: this one resolves INSIDE, so
+ containment passes and only `_occupied` stands between the copy and the wrong file.
+ """
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ slot = wt / tree / DEV_PRIMITIVE_NEW / "customize.toml"
+ slot.parent.mkdir(parents=True)
+ elsewhere = wt / tree / DEV_PRIMITIVE_NEW / "not-checked-out.toml"
+ slot.symlink_to(elsewhere) # dangling, but resolving INSIDE the worktree
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert not elsewhere.exists() # the bytes never landed at the link's target
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}/customize.toml"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_survives_a_dangling_symlink_directory_component(tmp_path):
+ """The `mkdir` crash site, and the proof that the leaf `try` is not redundant with
+ `_occupied`: measured, `target.is_symlink()` is FALSE when it is the target's PARENT
+ that dangles, so `_occupied` waves this through and `mkdir(parents=True,
+ exist_ok=True)` raises `FileExistsError` (its recovery is `if not exist_ok or not
+ self.is_dir(): raise`, and `is_dir()` is False on a dangling link).
+
+ Unguarded that leaves provisioning by the one door the doctrine forbids. Degraded,
+ it is one rel in `skipped` and every sibling file still lands."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ stand_in = wt / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ stand_in.parent.mkdir(parents=True)
+ stand_in.symlink_to(wt / tree / DEV_PRIMITIVE_NEW / "not-checked-out")
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}/review-prompts/adversarial.md"]
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unreadable_source_file(tmp_path):
+ """The other crash site the same `try` covers, and it fails at a different CALL —
+ `shutil.copy2`, not `mkdir`. Two witnesses because deleting either half of the
+ two-statement body leaves the other one's test green.
+
+ A chmod-000 source passes `is_file()` (its parent is readable, so the stat works)
+ and then raises `PermissionError` on the read. Nothing lands at the destination:
+ `copyfile` opens the source before the destination, so there is no truncated file to
+ mistake for a delivery. POSIX-only, non-root runner."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ locked = repo / tree / DEV_PRIMITIVE_NEW / "customize.toml"
+ locked.chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ finally:
+ locked.chmod(0o644)
+
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}/customize.toml"]
+ assert not (wt / tree / DEV_PRIMITIVE_NEW / "customize.toml").exists()
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_does_not_materialize_an_undescendable_source_dir(tmp_path):
+ """What the copier's `is_file()` filter buys once the leaf `try` is there to catch
+ the crash: the destination is not littered with directories the copy can never fill.
+
+ Without it `_copy_traversable` takes the directory branch, `mkdir`s the destination,
+ and only THEN fails listing the source — so the worktree ends up carrying an empty
+ `review-prompts/` that no file will ever arrive in. The rel is still reported either
+ way, which is exactly why this asserts the directory's absence rather than
+ `skipped`: that half is the gate's witness, not this one's. POSIX-only, non-root."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ locked = repo / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ locked.chmod(0o000)
+ try:
+ provision_worktree(wt, [claude], repo)
+ finally:
+ locked.chmod(0o755)
+
+ assert not (wt / tree / DEV_PRIMITIVE_NEW / "review-prompts").exists()
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_reports_the_children_of_an_unsearchable_source_dir(tmp_path):
+ """The same doctrine end to end for the `0o444` fault, and the finer report it
+ earns: the sibling above names one DIRECTORY rel because nothing could be listed,
+ this names the FILE because `iterdir` gave up the name and only the stat was
+ refused.
+
+ Ablating the walk's recursion probe back to the raw call reddens this on
+ 3.11/3.12/3.13, where it raises `PermissionError` straight out of
+ `provision_worktree` — the crash this cluster exists to prevent, at the one call the
+ phase's first pass left bare. Measured, that same ablation is GREEN on 3.14 (0 reds,
+ whole suite): the raw call answers False there and the walk yields the identical
+ leaf. What the 3.14 lane depends on instead is `_is_dir`'s re-ask — ablating THAT
+ reddens 3 on 3.14 against 1 on 3.13, and this test is one of the two extra. The two
+ ablations are therefore not interchangeable, and neither lane's local suite covers
+ both readings on its own.
+
+ The destination is not littered either: the copier's `_is_file` is False for an
+ entry it cannot classify, so no empty `review-prompts/` is created for a file that
+ will never arrive. POSIX-only, non-root runner."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ listable = repo / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ listable.chmod(0o444)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ finally:
+ listable.chmod(0o755)
+
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}/review-prompts/adversarial.md"]
+ assert not (wt / tree / DEV_PRIMITIVE_NEW / "review-prompts").exists()
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_seeds_a_read_only_source_dir_without_reporting_it(tmp_path):
+ """The false-positive guard for the pair above, and the reason `0o444` and `0o555`
+ must never be conflated: `0o555` is read-only but still SEARCHABLE, so every stat
+ succeeds and there is no fault at all.
+
+ `_is_dir` answers True on a probe it cannot complete, which is deliberately the
+ loud direction — so the thing to prove is that it is not loud on a directory that
+ merely cannot be written to. A read-only skill tree is an ordinary thing to have;
+ if this reported, every such project would take a CRITICAL escalation on every
+ story.
+
+ ⚠️ What it pins is the NARROWNESS of that loud reading rather than any one guard, so
+ reverting the guards around it leaves it green — that is the property a
+ false-positive control must have, and also what makes it look inert next to rows
+ that name their reds. It is not inert. It reddens on a `_is_dir` that widens past
+ the exception it exists for, and each of its two assertions catches a different
+ widening: a constant-True `_is_dir` fails `skipped == []` with four spurious rels,
+ and an `os.access(W_OK)`-based one — the tempting "can we actually use this
+ directory" reading — fails the delivery assert, because a read-only source stops
+ being copied at all. POSIX-only, non-root runner."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ read_only = repo / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ read_only.chmod(0o555)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ finally:
+ read_only.chmod(0o755)
+
+ assert skipped == []
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "review-prompts" / "adversarial.md").is_file()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_a_dangling_repo_symlink_is_dropped_by_the_copier_and_the_gate(tmp_path):
+ """The direction the gate's source filter has to get right in reverse: a broken
+ link the copy could never have followed must NOT halt the backlog.
+
+ A dangling symlink is neither file nor directory, so both halves drop it — the
+ copier has no bytes to deliver and the gate has nothing to demand. Without the
+ filter every such link becomes a CRITICAL escalation on every story, over a file the
+ operator's own repo cannot produce either. What the repo's own content should be is
+ the run-start preflight's question (`skills.base-missing`/`-incomplete`/`-shim`,
+ `skills.dev-renderer-sources`); this one asks only whether the copy delivered what
+ the repo has."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ broken = repo / tree / DEV_PRIMITIVE_NEW / "notes.md"
+ broken.symlink_to(repo / tree / DEV_PRIMITIVE_NEW / "not-installed.md")
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ assert skipped == []
+ assert not (wt / tree / DEV_PRIMITIVE_NEW / "notes.md").exists()
+ assert (wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file() # the rest landed
+
+
+def _seed_pair(tmp_path, skills, tree=".claude/skills"):
+ """A repo carrying ``skills`` and a worktree carrying the same — the state
+ `provision_worktree` produces when every copy lands. Callers then delete from the
+ worktree to model whatever the containment guard dropped, which is all
+ `base_skills_seed_incomplete` reads: it walks the REPO's dir and asks the worktree
+ for each rel behind a `SKILL.md` precondition, never how the worktree's copy of one
+ got there.
+
+ Writing the same set to both roots is what keeps this fixture silent under walk
+ parity — a rel only appears when a caller deletes it from ``wt``.
+ """
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ for root in (repo, wt):
+ for skill, markers in skills.items():
+ d = root / tree / skill
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "SKILL.md").write_text(f"# {skill}\n", encoding="utf-8")
+ for marker in markers:
+ (d / marker).write_text("x\n", encoding="utf-8")
+ return wt, repo, tree
+
+
+def test_base_skills_seed_incomplete_ignores_the_unused_primitive_era(tmp_path):
+ """BASE_SKILLS names both eras because copy-if-present makes naming both free; that
+ does NOT make both required. With bmad-build-auto resolved every prompt spells it,
+ so a dropped bmad-dev-auto leftover cannot stall a session — and the caller's
+ response to any rel here is a CRITICAL escalation that halts the whole run."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ shutil.rmtree(wt / tree / DEV_PRIMITIVE_LEGACY)
+
+ assert resolve_dev_primitive(repo, tree) == DEV_PRIMITIVE_NEW
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+def test_base_skills_seed_incomplete_ignores_an_unresolvable_new_primitive_dir(tmp_path):
+ """The same over-breadth in the other direction, and invisible to the preflight: a
+ `SKILL.md`-less bmad-build-auto dir (an aborted install) is not resolvable and is
+ never stat'ed once the legacy name resolves — but `is_dir()` is all the gate asks."""
+ wt, repo, tree = _seed_pair(tmp_path, DEV_BASE_SKILLS)
+ (repo / tree / DEV_PRIMITIVE_NEW).mkdir()
+
+ assert resolve_dev_primitive(repo, tree) == DEV_PRIMITIVE_LEGACY
+ assert missing_base_skills(repo, [tree]) == []
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+def test_base_skills_seed_incomplete_reports_a_dropped_legacy_primitive(tmp_path):
+ """The bound on the narrowing above: scoping to the RESOLVED primitive is not
+ scoping to the new name. A pre-rename project drives bmad-dev-auto, so a worktree
+ that lost it stalls exactly as hard and must still pause."""
+ wt, repo, tree = _seed_pair(tmp_path, DEV_BASE_SKILLS)
+ shutil.rmtree(wt / tree / DEV_PRIMITIVE_LEGACY)
+
+ assert resolve_dev_primitive(repo, tree) == DEV_PRIMITIVE_LEGACY
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [f"{tree}/{DEV_PRIMITIVE_LEGACY}"]
+
+
+def test_base_skills_seed_incomplete_reports_a_dropped_bmad_review(tmp_path):
+ """bmad-review is deliberately outside the preflight so pre-merge bmm installs keep
+ validating — but on a merged-lens install the three hunter ids are thin forwarders
+ to it, so a worktree that lacks one the repo HAS breaks those forwards. Preflight-
+ optional is not the same question as seeded-or-not, and the repo-has-it conjunct
+ already keeps the pre-merge case silent."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ shutil.rmtree(wt / tree / "bmad-review")
+
+ assert missing_base_skills(repo, [tree]) == [] # the preflight never asks for it
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [f"{tree}/bmad-review"]
+
+
+def test_base_skills_seed_incomplete_reports_a_short_skill_dir(tmp_path):
+ """The dir is PRESENT and dispatchable — SKILL.md is right there — and one required
+ file inside it is not. A directory-granular gate calls that complete, so the run
+ proceeds and every dev session's step-04 resolves its review layers from a
+ customize.toml that was never delivered. The rel has to name the file: a rel the
+ operator falsifies with one `ls` teaches them to stop reading the gate.
+
+ The destination is a DIRECTORY rather than simply absent, which is also the shape
+ that separates `is_file` from `exists` (see the sibling test below)."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ short = wt / tree / DEV_PRIMITIVE_NEW / "customize.toml"
+ short.unlink()
+ short.mkdir()
+
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [
+ f"{tree}/{DEV_PRIMITIVE_NEW}/customize.toml"
+ ]
+
+
+def test_base_skills_seed_incomplete_asks_is_file_not_exists(tmp_path):
+ """The leg that fixture arms, asserted for its own sake: a destination occupied by
+ a DIRECTORY is not the file the skill needs. `exists()` answers True for it, so the
+ dir would be called complete and the session dispatched against a customize.toml
+ the renderer cannot read — the same result-less Stop, reached by a path the absent
+ case never visits."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ occupied = wt / tree / DEV_PRIMITIVE_NEW / "customize.toml"
+ occupied.unlink()
+ occupied.mkdir()
+
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [
+ f"{tree}/{DEV_PRIMITIVE_NEW}/customize.toml"
+ ]
+
+
+def test_base_skills_seed_incomplete_cannot_demand_what_the_repo_lacks(tmp_path):
+ """The repo-has-it conjunct, which walk parity makes STRUCTURAL rather than a leg:
+ the walk enumerates the repo, so an install predating a file is never asked for
+ something no copy could have delivered. Provisioning cannot seed a file that does
+ not exist upstream, so a report here would be a CRITICAL escalation on every story
+ with no remedy the operator can apply — and the preflight, which owns "your install
+ is truncated", already said so once at run start.
+
+ Kept for its regression value; it no longer has ablation value, because there is no
+ longer a conjunct to delete — only a walk that cannot yield an absent file."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ for root in (repo, wt):
+ (root / tree / DEV_PRIMITIVE_NEW / "customize.toml").unlink()
+
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+def test_a_repo_only_file_is_delivered_so_parity_stays_silent(tmp_path):
+ """What makes walk parity safe — and the rewrite of the test that pinned the
+ opposite policy.
+
+ The old gate asked for `SKILL.md` plus the two markers, and argued that parity
+ "would halt the whole backlog over a repo-only README.md no session reads, with no
+ remedy available to the operator". The per-FILE merge landed in the same commit as
+ that argument and retired it: the copier filters nothing by name, so a repo-only
+ `README.md` — and a nested `scripts/helper.pyc` — are DELIVERED like every other
+ file, and parity has nothing to say about them. The only thing parity can report is
+ a file the seed FAILED to deliver, and every one of those has an operator remedy.
+
+ Driven through the real `provision_worktree` rather than a hand-built pair, which
+ is the substantive change: the old test wrote both sides itself, so it asserted a
+ policy the copier never had a say in.
+
+ The worktree carries ONE file of the dir on purpose. That is what discriminates —
+ against an EMPTY destination even a directory-granular merge copies everything and
+ this passes without the per-file half (ablation A15)."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ repo_skill = install_build_auto_skill(repo, tree)
+ (repo_skill / "README.md").write_text("# local notes\n", encoding="utf-8")
+ (repo_skill / "scripts").mkdir()
+ (repo_skill / "scripts" / "helper.pyc").write_bytes(b"\x00\x01")
+ # the partially-carried checkout: a dir-granular merge calls this "already there"
+ carried = wt / tree / DEV_PRIMITIVE_NEW / "SKILL.md"
+ carried.parent.mkdir(parents=True)
+ carried.write_text("# bmad-build-auto\n", encoding="utf-8")
+
+ skipped = provision_worktree(wt, [claude], repo)
+
+ wt_skill = wt / tree / DEV_PRIMITIVE_NEW
+ assert (wt_skill / "README.md").is_file()
+ assert (wt_skill / "scripts" / "helper.pyc").is_file()
+ assert skipped == []
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+def test_a_dropped_nested_file_is_reported_with_a_posix_rel(tmp_path):
+ """The rel a NESTED casualty reports, and this file's sole Windows witness.
+
+ The walk builds rels by concatenation, never `relative_to`, whose separator is the
+ host's. On Linux `os.sep == "/"` makes the two spellings identical, so a
+ `relative_to` regression is invisible here by construction and only a Windows job
+ can catch it — which is why this test is symlink-free and carries NO skipif.
+
+ A backslash rel does not crash: every consumer treats it as an opaque string (the
+ escalation message, the `/`-anchored git exclude patterns, the exact-match sentinel
+ filter), so it quietly stops matching. That is the failure mode that ships."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ nested = repo / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ nested.mkdir()
+ (nested / "adversarial.md").write_text("# adversarial\n", encoding="utf-8")
+
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [
+ f"{tree}/{DEV_PRIMITIVE_NEW}/review-prompts/adversarial.md"
+ ]
+
+
+def test_base_skills_seed_incomplete_ignores_a_repo_dir_with_no_skill_md(tmp_path):
+ """The precondition is `SKILL.md`, not `is_dir()`: a repo dir with no SKILL.md is
+ not a dispatchable skill — nothing spells it — so its absence downstream stalls
+ nothing, and pausing over it refuses a run nothing is wrong with. The preflight
+ already owns that report, and does make it, so narrowing here loses no coverage.
+
+ A review hunter rather than the dev primitive: a SKILL.md-less bmad-build-auto
+ falls into the unused-era skip one branch earlier and would prove nothing."""
+ hunter = "bmad-review-verification-gap"
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ shutil.rmtree(wt / tree / hunter)
+ (repo / tree / hunter / "SKILL.md").unlink()
+
+ problems = missing_base_skills(repo, [tree])
+ assert [f.check for f in problems] == ["skills.base-missing"]
+ assert problems[0].detail["skill"] == hunter
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+def test_base_skills_seed_incomplete_ignores_a_short_dir_in_the_unused_era(tmp_path):
+ """Composition of the two narrowings: per-FILE granularity must not re-widen the
+ era gate. With bmad-build-auto resolved every prompt spells it, so a leftover
+ bmad-dev-auto shim is never dispatched and a SHORT copy of it stalls exactly as
+ much as an absent one — nothing. The finer rels make this the easier mistake to
+ make, since the skill now fails the check on a file rather than on its whole dir."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ (wt / tree / DEV_PRIMITIVE_LEGACY / "customize.toml").unlink()
+
+ assert resolve_dev_primitive(repo, tree) == DEV_PRIMITIVE_NEW
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == []
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_base_skills_seed_incomplete_splits_unreadable_from_absent(tmp_path):
+ """The `SKILL.md` precondition answers False for two unlike facts, and collapsing
+ them is the quiet failure: "this project legitimately lacks that skill" is benign
+ and the run walks on, while "the skill is there and we cannot open it" means the
+ worktree has none of it either and every session that reaches for it stalls.
+
+ Only the walk can tell them apart. An undescendable directory is the one thing it
+ yields as a leaf; an absent path is neither file nor directory, so there is nothing
+ to see. Both shapes are armed in one test because the split is the claim — either
+ one alone passes under an ablation that answers the same for both.
+
+ A review hunter rather than the dev primitive: an unreadable `bmad-build-auto` falls
+ into the unused-era skip one branch earlier (see the sibling test) and would prove
+ nothing here. POSIX-only, and it assumes a non-root runner."""
+ unreadable = "bmad-review-verification-gap"
+ absent = "bmad-review-edge-case-hunter"
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ for root in (repo, wt):
+ shutil.rmtree(root / tree / absent)
+ shutil.rmtree(wt / tree / unreadable)
+ (repo / tree / unreadable).chmod(0o000)
+ try:
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [f"{tree}/{unreadable}"]
+ finally:
+ (repo / tree / unreadable).chmod(0o755)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_base_skills_seed_incomplete_names_an_unreadable_repo_subdir(tmp_path):
+ """The gate's source filter is `_is_file or _is_dir` where the copier's is
+ `_is_file` alone, and the only yielded entry that answers the second alone is a
+ directory the walk could not see inside — a descendable one is recursed into, and a
+ cycle repeat is dropped with no rel.
+
+ That single predicate is the whole difference between the two halves of the seed,
+ and dropping the `_is_dir` half restores the exact pairing this cluster exists to
+ end: an unreadable subtree copied as nothing and reported as complete. The rel names
+ the directory because that is all anyone honestly knows about it — where a dir that
+ can be LISTED but not stat'd through names its children instead (see the `0o444`
+ witnesses). POSIX-only, non-root runner."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ locked = repo / tree / DEV_PRIMITIVE_NEW / "review-prompts"
+ locked.mkdir()
+ (locked / "adversarial.md").write_text("# adversarial\n", encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ assert base_skills_seed_incomplete(wt, repo, [tree]) == [
+ f"{tree}/{DEV_PRIMITIVE_NEW}/review-prompts"
+ ]
+ finally:
+ locked.chmod(0o755)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unreadable_dev_primitive_dir(tmp_path):
+ """`resolve_dev_primitive` is not a preflight-only resolver: this gate asks it, on
+ every provision, through `dev_primitive_or_default`. Measured with its bare
+ `is_file()` probes, an unreadable `bmad-build-auto` dir in the repo took
+ `provision_worktree` itself down with `PermissionError` through 3.13 — a raise out
+ of `Engine._run_isolated` from the function whose whole contract is never to raise —
+ while 3.14 answered False and fell through.
+
+ Total probes make both lanes do what 3.14 did: fall through to the complete LEGACY
+ install, which is dispatchable, so its era is the used one and the unreadable dir is
+ rightly never asked about. The silence is the correct answer here; what was wrong
+ was the crash. POSIX-only, non-root runner."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ claude = get_profile("claude")
+ locked = repo / tree / DEV_PRIMITIVE_NEW
+ locked.chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ assert resolve_dev_primitive(repo, tree) == DEV_PRIMITIVE_LEGACY
+ finally:
+ locked.chmod(0o755)
+
+ assert skipped == []
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unreadable_skill_tree(tmp_path):
+ """One level above every other fixture here: not a skill dir the walk cannot read,
+ but the TREE that holds them — so the walk root's own parent is unsearchable and
+ even `is_dir()` on the root raises.
+
+ Probes stacked behind probes, and each only became reachable once the one in front
+ of it was fixed — the same shape as the phase's earlier findings. Measured on
+ 3.11/3.12/3.13, one raw probe at a time, in the order they surface: the `BASE_SKILLS`
+ copy loop's own `src_root` guard; then the walk's root probe, reached from the copy
+ that guard admits (`provision_worktree` -> `_merge_traversable` ->
+ `_walk_traversable_files`) and NOT from the gate; then, inside the gate itself,
+ `resolve_dev_primitive`'s `SKILL.md` probes (via `dev_primitive_or_default`), the
+ gate's own `SKILL.md` precondition, and last its absent-vs-unreadable split. Five,
+ not two — each verified by reverting exactly that one call and reading the frame the
+ `PermissionError` comes out of; the order is the frame order in `provision_worktree`
+ itself.
+
+ Every skill is named, and naming them is the point: nothing can be confirmed
+ delivered when the repo side cannot be read at all, and the benign reading — "this
+ project has no skills" — is the silence the split exists to refuse. The worktree
+ here HAS every skill, which is what makes the assertion mean something: the report
+ is about what could be verified, not about what happens to be on disk. The dev
+ primitive is absent from the list because the era resolves away from it one branch
+ earlier (see the sibling test). POSIX-only, non-root runner."""
+ wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS)
+ claude = get_profile("claude")
+ (repo / tree).chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ finally:
+ (repo / tree).chmod(0o755)
+
+ assert skipped == [f"{tree}/{skill}" for skill in BASE_SKILLS if skill != DEV_PRIMITIVE_NEW]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unwritable_destination_dir(tmp_path):
+ """The destination half of the same doctrine: a skill directory in the CHECKOUT
+ that cannot be written into, or even stat'd through.
+
+ Through 3.13 `_occupied`'s `exists()` raises on it and totality is what keeps that
+ inside the loop; on 3.14 it answers False and the write fails into the leaf `except`
+ instead. Two paths, one outcome — nothing written, the whole dir named — which is
+ what this asserts, because a return-value assertion would pin one interpreter's
+ reading and redden on the other (`_occupied` is True on 3.11/3.12/3.13 and False on
+ 3.14). The rel is coarse because the worktree's `SKILL.md` is
+ unreadable too: nothing under that dir is dispatchable. POSIX-only, non-root."""
+ from conftest import install_build_auto_skill
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+ locked = wt / tree / DEV_PRIMITIVE_NEW
+ locked.mkdir(parents=True)
+ locked.chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [claude], repo)
+ finally:
+ locked.chmod(0o755)
+
+ assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}"]
+ assert list(locked.iterdir()) == []
+
+
def test_missing_base_skills_reports_absent_and_incomplete(tmp_path):
+ """Markers are asserted on the primitive that RESOLVED. Truncating the legacy
+ name instead is a different finding (it stops resolving at all) — see
+ test_legacy_primitive_missing_a_marker_is_refused."""
+ primitive = "bmad-build-auto"
claude = get_profile("claude")
# nothing installed → dev primitive + all three inline review hunters reported
- # missing (the hunters are always required — bmad-dev-auto's step-04 invokes
+ # missing (the hunters are always required — the primitive's step-04 invokes
# them on every run, regardless of the orchestrator's follow-up review)
problems = missing_base_skills(tmp_path, [claude.skill_tree])
assert len(problems) == 4
@@ -612,31 +1536,923 @@ def test_missing_base_skills_reports_absent_and_incomplete(tmp_path):
_install_base_skills(tmp_path, claude.skill_tree)
assert missing_base_skills(tmp_path, [claude.skill_tree]) == []
- # remove the dev primitive's step-file marker → reported as incomplete
- (tmp_path / claude.skill_tree / "bmad-dev-auto" / "step-04-review.md").unlink()
+ # remove the resolved primitive's step-file marker → reported as incomplete
+ (tmp_path / claude.skill_tree / primitive / "step-04-review.md").unlink()
problems = missing_base_skills(tmp_path, [claude.skill_tree])
assert len(problems) == 1
assert "incomplete" in problems[0].message
assert "step-04-review.md" in problems[0].message
+ assert problems[0].detail["skill"] == primitive
# restore it, then drop customize.toml (the review-layer config marker,
# BMAD-METHOD #2535/#2550) → a pre-July bmm install is caught as incomplete
- (tmp_path / claude.skill_tree / "bmad-dev-auto" / "step-04-review.md").write_text("x\n")
- (tmp_path / claude.skill_tree / "bmad-dev-auto" / "customize.toml").unlink()
+ (tmp_path / claude.skill_tree / primitive / "step-04-review.md").write_text("x\n")
+ (tmp_path / claude.skill_tree / primitive / "customize.toml").unlink()
problems = missing_base_skills(tmp_path, [claude.skill_tree])
assert len(problems) == 1
assert "incomplete" in problems[0].message
assert "customize.toml" in problems[0].message
+ assert problems[0].detail["skill"] == primitive
+
+ # the newest review layer (verification-gap) reported by name when absent
+ _install_base_skills(tmp_path, claude.skill_tree) # re-complete everything
+ shutil.rmtree(tmp_path / claude.skill_tree / "bmad-review-verification-gap")
+ problems = missing_base_skills(tmp_path, [claude.skill_tree])
+ assert len(problems) == 1
+ assert "bmad-review-verification-gap" in problems[0].message
+ assert "install the BMad Method" in problems[0].message
+
+
+# --- the bmad-dev-auto -> bmad-build-auto rename (BMAD-METHOD #2651) ---------
+
+
+def _install_hunters(root, tree=".claude/skills"):
+ """Just the three inline review hunters — so a resolution test's findings are
+ only ever about the dev primitive."""
+ from bmad_loop.install import REVIEW_HUNTER_SKILLS
+
+ for skill in REVIEW_HUNTER_SKILLS:
+ d = root / tree / skill
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "SKILL.md").write_text(f"# {skill}\n", encoding="utf-8")
+
+
+def _install_renderer_surface(root):
+ """The PROJECT-global half of a renderer-era install: the script and the central
+ config layer. Lets a test about a skill's own render SOURCES speak only about
+ those — without it every such test also carries `skills.dev-renderer` and
+ `skills.dev-renderer-config`, and an assert on the check list stops discriminating.
+ The script body deliberately does not import `config_utils`, so the sibling is not
+ required (see `_renderer_unit_required`) and one file is enough."""
+ from bmad_loop.install import CENTRAL_CONFIG_REL, RENDERER_SCRIPT_REL
+
+ script = root / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# renderer\n", encoding="utf-8")
+ (root / CENTRAL_CONFIG_REL).write_text('[core]\nname = "x"\n', encoding="utf-8")
+
+
+def _install_legacy_primitive(root, tree=".claude/skills"):
+ """A complete PRE-rename dev primitive: SKILL.md plus every marker."""
+ from bmad_loop.install import DEV_PRIMITIVE_LEGACY, DEV_PRIMITIVE_MARKERS
+
+ d = root / tree / DEV_PRIMITIVE_LEGACY
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "SKILL.md").write_text(f"# {DEV_PRIMITIVE_LEGACY}\n", encoding="utf-8")
+ for marker in DEV_PRIMITIVE_MARKERS:
+ (d / marker).write_text("x\n", encoding="utf-8")
+ return d
+
+
+def test_resolve_dev_primitive_prefers_the_new_name_over_a_complete_legacy(tmp_path):
+ """The rename's four on-disk states, resolved. The shim is the one that must
+ NOT resolve: its migration gate is interactive, so an unattended session
+ dispatched to it HALTs without writing a spec — worse than failing preflight."""
+ from conftest import install_build_auto_skill, install_dev_shim
+
+ from bmad_loop.install import resolve_dev_primitive
+
+ tree = get_profile("claude").skill_tree
+
+ # 1. nothing installed
+ assert resolve_dev_primitive(tmp_path, tree) is None
+
+ # 2. shim only → still nothing usable
+ install_dev_shim(tmp_path, tree)
+ assert resolve_dev_primitive(tmp_path, tree) is None
+
+ # 3. shim + the real new skill (what an upgraded bmm install looks like)
+ install_build_auto_skill(tmp_path, tree)
+ assert resolve_dev_primitive(tmp_path, tree) == "bmad-build-auto"
+
+ # 4. a complete legacy install alone (pre-rename project)
+ shutil.rmtree(tmp_path / tree / "bmad-build-auto")
+ shutil.rmtree(tmp_path / tree / "bmad-dev-auto")
+ _install_legacy_primitive(tmp_path, tree)
+ assert resolve_dev_primitive(tmp_path, tree) == "bmad-dev-auto"
+
+ # …and the new name wins the moment it appears beside it
+ install_build_auto_skill(tmp_path, tree)
+ assert resolve_dev_primitive(tmp_path, tree) == "bmad-build-auto"
+
+
+def test_resolve_dev_primitive_takes_the_new_name_without_its_markers(tmp_path):
+ """Resolution keys on SKILL.md alone for the new name so a truncated
+ bmad-build-auto is REPORTED (base-incomplete) rather than silently falling
+ through to a legacy install — or to the shim's misleading message."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills, resolve_dev_primitive
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_legacy_primitive(tmp_path, tree)
+ install_build_auto_skill(tmp_path, tree)
+ (tmp_path / tree / "bmad-build-auto" / "customize.toml").unlink()
+
+ assert resolve_dev_primitive(tmp_path, tree) == "bmad-build-auto"
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.base-incomplete"]
+ assert problems[0].detail["skill"] == "bmad-build-auto"
+
+
+def test_shim_only_install_fails_the_preflight_with_the_rename_named(tmp_path):
+ """The headline case: a project upgraded past #2651 has ONLY the shim under the
+ old name, and v0.9.0's marker check would have called it `base-incomplete` —
+ a message pointing at the wrong remedy (reinstall) for the actual cause."""
+ from conftest import install_dev_shim
+
+ from bmad_loop.checks import VALIDATE_CHECKS
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ install_dev_shim(tmp_path, tree)
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.base-shim"]
+ assert problems[0].check in VALIDATE_CHECKS
+ assert problems[0].severity == "problem"
+ assert problems[0].detail == {
+ "tree": tree,
+ "skill": "bmad-dev-auto",
+ "expected": "bmad-build-auto",
+ "missing_markers": ["step-04-review.md", "customize.toml"],
+ }
+ assert "bmad-build-auto" in problems[0].message
+ assert "HALT" in problems[0].message # the hazard, not just the absence
+
+
+def test_legacy_primitive_missing_a_marker_is_refused(tmp_path):
+ """A truncated legacy install is the same on-disk shape as the shim, so it
+ lands on the same finding. What must NOT happen is resolving it and driving a
+ primitive whose step-04 config is absent."""
+ from bmad_loop.install import missing_base_skills, resolve_dev_primitive
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_legacy_primitive(tmp_path, tree)
+ (tmp_path / tree / "bmad-dev-auto" / "step-04-review.md").unlink()
+
+ assert resolve_dev_primitive(tmp_path, tree) is None
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.base-shim"]
+ assert problems[0].detail["missing_markers"] == ["step-04-review.md"]
+
+
+def test_no_primitive_at_all_names_the_current_upstream_skill(tmp_path):
+ """An empty tree is `base-missing`, not `base-shim` — and it points at the new
+ name, because that is what a fresh bmm install would lay down."""
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.base-missing"]
+ assert problems[0].detail["skill"] == "bmad-build-auto"
+ assert "bmad-dev-auto" in problems[0].message # older installs still named
+
+
+def test_dev_primitive_or_default_is_total(tmp_path):
+ """Prompt builders need a name unconditionally. A None tree (an adapter with no
+ profile) and an unresolvable tree both fall back to the legacy name — the
+ preflight has already refused the real cases before any session is spawned."""
+ from conftest import install_build_auto_skill, install_dev_shim
+
+ from bmad_loop.install import dev_primitive_or_default
+
+ tree = get_profile("claude").skill_tree
+ assert dev_primitive_or_default(tmp_path, None) == "bmad-dev-auto"
+ assert dev_primitive_or_default(tmp_path, tree) == "bmad-dev-auto"
+ install_dev_shim(tmp_path, tree)
+ assert dev_primitive_or_default(tmp_path, tree) == "bmad-dev-auto"
+ install_build_auto_skill(tmp_path, tree)
+ assert dev_primitive_or_default(tmp_path, tree) == "bmad-build-auto"
+
+
+def test_resolution_is_per_tree(tmp_path):
+ """One run can drive two CLIs with different skill trees, and an operator can
+ upgrade bmm in one and not the other — so resolution is per tree, and both a
+ finding and a resolved name have to follow the tree they came from."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills, resolve_dev_primitive
+
+ claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_tree
+ assert claude != codex
+ for tree in (claude, codex):
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, claude)
+ _install_legacy_primitive(tmp_path, codex)
+
+ assert resolve_dev_primitive(tmp_path, claude) == "bmad-build-auto"
+ assert resolve_dev_primitive(tmp_path, codex) == "bmad-dev-auto"
+ assert missing_base_skills(tmp_path, [claude, codex]) == []
+
+
+def test_renderer_stub_without_its_script_fails_the_preflight(tmp_path):
+ """BMAD-METHOD #2601: SKILL.md became a stub that shells out to a project-local
+ render script. Missing script = the session HALTs with nothing written on EVERY
+ story, so it is a preflight problem, not an advisory warning — the same failure
+ shape `skills.base-shim` already blocks on. That the probe is not sufficient for
+ success (uv on PATH is never checked) argues against trusting a green, not
+ against blocking on a red."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.checks import VALIDATE_CHECKS
+ from bmad_loop.install import CENTRAL_CONFIG_REL, RENDERER_SCRIPT_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ # the OTHER renderer prerequisite, so this test speaks only about the script
+ config = tmp_path / CENTRAL_CONFIG_REL
+ config.parent.mkdir(parents=True, exist_ok=True)
+ config.write_text('[core]\nname = "x"\n', encoding="utf-8")
+ # an inline (non-stub) SKILL.md never fires, script or no script
+ install_build_auto_skill(tmp_path, tree)
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+ install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer"]
+ assert problems[0].severity == "problem"
+ assert problems[0].check in VALIDATE_CHECKS
+ assert RENDERER_SCRIPT_REL in problems[0].message
+
+ # …and it clears once the script is there
+ script = tmp_path / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# renderer\n", encoding="utf-8")
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_renderer_stub_without_config_utils_fails_the_preflight(tmp_path):
+ """The renderer is a TWO-file unit: `render_skill.py` imports `config_utils` at
+ module scope off sys.path[0], above its own try/except, so the sibling's absence
+ is a bare ModuleNotFoundError — the same result-less Stop, minus even the
+ structured `HALT: ` line. One check id for both members: same finding,
+ same remediation; `missing_scripts` says which.
+
+ Both files are written BY NAME rather than through a helper keyed on
+ RENDERER_SCRIPT_UNIT_REL — a constant-keyed helper is self-satisfying under the
+ ablation this test exists for ("drop the sibling from the unit tuple")."""
+ from conftest import RENDERER_SCRIPT_IMPORTING_SIBLING, install_build_auto_skill
+
+ from bmad_loop.checks import VALIDATE_CHECKS
+ from bmad_loop.install import (
+ CENTRAL_CONFIG_REL,
+ RENDERER_CONFIG_UTILS_REL,
+ RENDERER_SCRIPT_REL,
+ missing_base_skills,
+ )
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ # the other two prerequisites, so this test speaks only about the sibling
+ config = tmp_path / CENTRAL_CONFIG_REL
+ config.parent.mkdir(parents=True, exist_ok=True)
+ config.write_text('[core]\nname = "x"\n', encoding="utf-8")
+ script = tmp_path / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text(RENDERER_SCRIPT_IMPORTING_SIBLING, encoding="utf-8")
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer"]
+ assert problems[0].severity == "problem"
+ assert problems[0].check in VALIDATE_CHECKS
+ assert problems[0].detail["missing_scripts"] == [RENDERER_CONFIG_UTILS_REL]
+ assert RENDERER_CONFIG_UTILS_REL in problems[0].message
+ # the entry point is present, so it is NOT reported alongside
+ assert RENDERER_SCRIPT_REL not in problems[0].detail["missing_scripts"]
+
+ # …and it clears once the sibling is there
+ (tmp_path / RENDERER_CONFIG_UTILS_REL).write_text("# helper\n", encoding="utf-8")
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_a_renderer_that_does_not_import_the_helper_is_not_gated(tmp_path):
+ """The far side of the content key. This gate is a hard block with no severity
+ filter and no `--force`, shipping against a file one commit old upstream: if a
+ later bmm inlines or renames the helper, the import disappears and the gate must
+ go QUIET rather than refuse every run with a remediation that cannot fix it.
+
+ Same layout as the test above, one byte different — the installed script never
+ says `config_utils` — so this is the discrimination itself, not a second absence
+ case (ablation: make `_renderer_unit_required` unconditional)."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import (
+ CENTRAL_CONFIG_REL,
+ RENDERER_CONFIG_UTILS_REL,
+ RENDERER_SCRIPT_REL,
+ missing_base_skills,
+ )
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ config = tmp_path / CENTRAL_CONFIG_REL
+ config.parent.mkdir(parents=True, exist_ok=True)
+ config.write_text('[core]\nname = "x"\n', encoding="utf-8")
+ script = tmp_path / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# a renderer that carries its own config loading\n", encoding="utf-8")
+
+ assert not (tmp_path / RENDERER_CONFIG_UTILS_REL).exists()
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_renderer_stub_without_central_config_fails_the_preflight(tmp_path):
+ """`_bmad/config.toml` is the renderer's one REQUIRED config layer
+ (`config_utils.load_central_config` passes required=True). Absent, the stub's
+ `uv run` raises before composing anything and exits `HALT:` — the same
+ result-less Stop the missing-script problem guards, one file further in, and
+ one no other check sees."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.checks import VALIDATE_CHECKS
+ from bmad_loop.install import CENTRAL_CONFIG_REL, RENDERER_SCRIPT_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+
+ # a wholly-absent _bmad/ earns BOTH lines — different files, different
+ # remediations, so neither is suppressed against the other
+ assert [f.check for f in missing_base_skills(tmp_path, [tree])] == [
+ "skills.dev-renderer",
+ "skills.dev-renderer-config",
+ ]
+
+ script = tmp_path / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# renderer\n", encoding="utf-8")
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-config"]
+ assert problems[0].severity == "problem"
+ assert problems[0].check in VALIDATE_CHECKS
+ assert CENTRAL_CONFIG_REL in problems[0].message
+ assert problems[0].detail["config"] == CENTRAL_CONFIG_REL
+
+ # …and it clears once the layer is there (ablation: drop the is_file predicate —
+ # invisible to the assert above, which keeps passing with the predicate gone)
+ (tmp_path / CENTRAL_CONFIG_REL).write_text('[core]\nname = "x"\n', encoding="utf-8")
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_renderer_checks_silent_for_pre_render_skill(tmp_path):
+ """Era-agnostic refusal: a pre-#2601 inline SKILL.md never reads the central
+ config, so its absence is not a finding to make about that project. The gate is
+ the resolved skill's own content — never a bare "`_bmad/config.toml` missing"
+ (ablation: hoist the check out of the stub gate)."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import CENTRAL_CONFIG_REL, missing_base_skills
+
+ claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_tree
+ for tree in (claude, codex):
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, claude) # inline SKILL.md, post-rename name
+ _install_legacy_primitive(tmp_path, codex) # inline SKILL.md, pre-rename name
+
+ assert not (tmp_path / CENTRAL_CONFIG_REL).exists()
+ assert missing_base_skills(tmp_path, [claude, codex]) == []
+
+
+def test_renderer_stub_resolved_is_per_tree_and_keyed_on_content(tmp_path):
+ """The predicate the worktree seed gate escalates on, lifted out of
+ `missing_base_skills` so the run-time gate and the preflight cannot drift apart.
+
+ Content-keyed, never name-keyed — the legacy leg is the one that proves it, and
+ nothing else in the suite covers `_is_renderer_stub` against a `bmad-dev-auto`
+ directory. ANY over trees, because one run can mix `.claude/skills` and
+ `.agents/skills` on different upstream eras."""
+ from conftest import RENDERER_STUB_SKILL_MD, install_build_auto_skill
+
+ from bmad_loop.install import DEV_PRIMITIVE_LEGACY, renderer_stub_resolved
+
+ claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_tree
+
+ assert renderer_stub_resolved(tmp_path, []) is False # no tree, nothing to resolve
+ assert renderer_stub_resolved(tmp_path, [claude]) is False # nothing installed
+
+ install_build_auto_skill(tmp_path, claude) # inline SKILL.md
+ assert renderer_stub_resolved(tmp_path, [claude]) is False
+
+ install_build_auto_skill(tmp_path, codex, renderer_stub=True)
+ assert renderer_stub_resolved(tmp_path, [codex]) is True
+ assert renderer_stub_resolved(tmp_path, [claude, codex]) is True # ANY, not ALL
+ assert renderer_stub_resolved(tmp_path, [claude, claude]) is False # dedupe is inert
+
+ # …and the era-agnosticism leg: a LEGACY-named primitive carrying stub content is
+ # just as much a renderer as the renamed one. Keyed on the SKILL.md, not the dir.
+ legacy_tree = ".legacy/skills" # a third tree, so the codex stub above cannot answer for it
+ _install_legacy_primitive(tmp_path, legacy_tree)
+ assert renderer_stub_resolved(tmp_path, [legacy_tree]) is False
+ (tmp_path / legacy_tree / DEV_PRIMITIVE_LEGACY / "SKILL.md").write_text(
+ RENDERER_STUB_SKILL_MD, encoding="utf-8"
+ )
+ assert renderer_stub_resolved(tmp_path, [legacy_tree]) is True
+
+
+def test_renderer_config_problem_emitted_once_across_two_trees(tmp_path):
+ """`_bmad/config.toml` is project-global like `_bmad/custom/`, so two stub trees
+ are one finding (ablation: emit inside the per-tree loop). The script is present
+ so the per-tree `skills.dev-renderer` cannot stand in for the once-ness."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_SCRIPT_REL, missing_base_skills
+
+ claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_tree
+ script = tmp_path / RENDERER_SCRIPT_REL
+ script.parent.mkdir(parents=True, exist_ok=True)
+ script.write_text("# renderer\n", encoding="utf-8")
+ for tree in (claude, codex):
+ _install_hunters(tmp_path, tree)
+ install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+
+ problems = missing_base_skills(tmp_path, [claude, codex])
+ assert [f.check for f in problems] == ["skills.dev-renderer-config"]
+
+
+def test_renderer_checks_reported_beside_a_truncated_primitive(tmp_path):
+ """The renderer probes are independent of the marker check: a stub that is BOTH
+ truncated and missing its script earns every line. Different files, different
+ remediations (ablation: put the renderer probe in an elif).
+
+ `skills.dev-renderer-sources` joins them because the deleted file is *also* the
+ one `RENDERER_WORKFLOW_MD` names — the conftest fixture points its token at a
+ DEV_PRIMITIVE_MARKER on purpose, so a healthy stub install always carries its own
+ target. The overlap is the fixture's, not a coupling in the code: the sources
+ check knows nothing of the marker tuple, and suppressing one against the other
+ would reintroduce exactly the named-list dependency the walk-parity reversal
+ removed. Two of the four ids below are that pair, and they say different
+ things — "this install is truncated" and "the prompt it would render
+ references a file that is not there"."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / "step-04-review.md").unlink()
+
+ assert [f.check for f in missing_base_skills(tmp_path, [tree])] == [
+ "skills.base-incomplete",
+ "skills.dev-renderer",
+ "skills.dev-renderer-sources",
+ "skills.dev-renderer-config",
+ ]
+
+
+def test_renderer_checks_are_silent_when_nothing_resolves(tmp_path):
+ """A shim whose SKILL.md is a renderer stub reports `skills.base-shim` and
+ NOTHING else: the renderer probes hang off the resolved branch, so an
+ unresolvable tree cannot pile a second remediation on top of the first
+ (ablation: hoist the stub probe above the resolution split)."""
+ from conftest import RENDERER_STUB_SKILL_MD, install_dev_shim
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ shim = install_dev_shim(tmp_path, tree)
+ (shim / "SKILL.md").write_text(RENDERER_STUB_SKILL_MD, encoding="utf-8")
+
+ assert [f.check for f in missing_base_skills(tmp_path, [tree])] == ["skills.base-shim"]
+
+
+# --- the renderer's own source set (BMAD-METHOD #2601) -----------------------
+
+
+def test_renderer_stub_without_its_workflow_entry_fails_the_preflight(tmp_path):
+ """`render_skill.py` composes the prompt from `/workflow.md` and raises
+ `render entry is missing` when it is absent — printed as `HALT:`, i.e. a
+ result-less Stop on EVERY story, the same environment fault the script and config
+ checks already refuse. The marker pair could not see this: two files answered for
+ the thirteen a real `bmad-build-auto` carries, so a truncated repo-side install
+ passed `validate` and then HALTed every session."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.checks import VALIDATE_CHECKS
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ assert missing_base_skills(tmp_path, [tree]) == [] # the fixture is a WHOLE install
+
+ (skill / RENDERER_ENTRY_REL).unlink()
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].severity == "problem"
+ assert problems[0].check in VALIDATE_CHECKS
+ assert RENDERER_ENTRY_REL in problems[0].message and "HALT" in problems[0].message
+ assert problems[0].detail == {
+ "tree": tree,
+ "skill": DEV_PRIMITIVE_NEW,
+ "missing_sources": [RENDERER_ENTRY_REL],
+ }
+
+
+def test_a_snapshot_token_naming_an_absent_source_fails_the_preflight(tmp_path):
+ """The other half of the same HALT: a `[[bmad-snapshot:…]]` target that is not one
+ of the skill's own sources raises `snapshot reference targets undeclared source`.
+ The entry leg cannot stand in for it — `workflow.md` is present here — which is
+ why the two are separate lines in the helper."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "Read fully and follow: [[bmad-snapshot:step-09-ship.md]]\n", encoding="utf-8"
+ )
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].detail["missing_sources"] == ["step-09-ship.md"]
+
+ # One entry, not three: the real install names `step-02-plan.md` four times from
+ # step-01 alone, so without the dedupe a single dropped file would repeat itself
+ # four times in the detail AND in the operator's message line
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "[[bmad-snapshot:step-09-ship.md]] " * 3, encoding="utf-8"
+ )
+ problems = missing_base_skills(tmp_path, [tree])
+ assert problems[0].detail["missing_sources"] == ["step-09-ship.md"]
+
+ # …and it clears once the source the prompt names is actually there
+ (skill / "step-09-ship.md").write_text("# ship\n", encoding="utf-8")
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_a_token_in_a_non_entry_source_is_scanned_too(tmp_path):
+ """Upstream substitutes tokens across every source it loaded, not just the entry,
+ so one undeclared reference anywhere in the tree fails the whole render. A gate
+ that read only `workflow.md` would call this install healthy and hand every story
+ to the same HALT."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ # the ENTRY stays healthy — its own token names a file the fixture carries
+ (skill / "step-02-plan.md").write_text(
+ "then read [[bmad-snapshot:step-99-nope.md]]\n", encoding="utf-8"
+ )
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].detail["missing_sources"] == ["step-99-nope.md"]
+
+
+def test_a_snapshot_token_naming_skill_md_is_reported(tmp_path):
+ """`SKILL.md` is the one file that is present on disk and still not a source:
+ upstream skips it by NAME, at any depth, when it collects the set a token may
+ target. So a prompt pointing at it HALTs exactly like a pointer at a file that
+ does not exist, and the gate has to answer the same way."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "Read fully and follow: [[bmad-snapshot:SKILL.md]]\n", encoding="utf-8"
+ )
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].detail["missing_sources"] == ["SKILL.md"]
+
+ # …"at any depth" is upstream's rule too — it keys the skip on the file's NAME,
+ # not its rel — so a nested one is no more targetable than the top-level one
+ (skill / "sub").mkdir()
+ (skill / "sub" / "SKILL.md").write_text("# nested\n", encoding="utf-8")
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "Read fully and follow: [[bmad-snapshot:sub/SKILL.md]]\n", encoding="utf-8"
+ )
+ problems = missing_base_skills(tmp_path, [tree])
+ assert problems[0].detail["missing_sources"] == ["sub/SKILL.md"]
+
+
+def test_the_inline_era_is_never_asked_for_render_sources(tmp_path):
+ """A pre-BMAD-METHOD#2601 `SKILL.md` carries its prompt inline: it has no
+ `workflow.md`, declares nothing, and renders nothing. Asking it the renderer's
+ questions would refuse every legitimate pre-renderer install on a check with no
+ severity filter and no `--force`.
+
+ The second half is what makes this a discriminator rather than an empty
+ observation: the very same directory, with ONLY `SKILL.md` swapped for the stub,
+ is reported. The silence upstream is the era, not an absence of anything to say."""
+ from conftest import RENDERER_STUB_SKILL_MD, install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree) # inline era: no workflow.md
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+ (skill / "SKILL.md").write_text(RENDERER_STUB_SKILL_MD, encoding="utf-8")
+ assert [f.check for f in missing_base_skills(tmp_path, [tree])] == [
+ "skills.dev-renderer-sources"
+ ]
+
+
+def test_a_reorganized_renderer_install_is_not_refused(tmp_path):
+ """The gate asks what the install DECLARES — never a fixed renderer-era file
+ list. Nothing here is named `step-*` and no marker is in sight, yet the render
+ graph is closed, so there is nothing to report. A hardcoded list would refuse
+ this project on every run with a remediation that could not fix it, which is the
+ failure mode a `--force`-less preflight cannot afford.
+
+ Flat by design: the nested case is a separate test, because the source key's
+ `as_posix()` is invisible on POSIX and this one must not depend on it."""
+ from conftest import RENDERER_STUB_SKILL_MD
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, _absent_renderer_sources
+
+ skill = tmp_path / DEV_PRIMITIVE_NEW
+ skill.mkdir(parents=True)
+ (skill / "SKILL.md").write_text(RENDERER_STUB_SKILL_MD, encoding="utf-8")
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "run [[bmad-snapshot:clarify.md]] then [[bmad-snapshot:plan.md]]\n", encoding="utf-8"
+ )
+ (skill / "clarify.md").write_text("# clarify\n", encoding="utf-8")
+ (skill / "plan.md").write_text("# plan\n", encoding="utf-8")
+
+ assert _absent_renderer_sources(skill) == []
+
+
+def test_a_nested_snapshot_target_is_keyed_by_its_posix_rel(tmp_path):
+ """Upstream keys a source by `relative_to(skill_dir).as_posix()`, so a nested
+ source is referenced as `phases/plan.md` and never by its bare name.
+
+ Deliberately symlink-free and UNGUARDED: this is the suite's only witness for the
+ `as_posix()` on the source key, and that ablation is invisible on Linux by
+ construction (`os.sep == "/"`). Windows CI is the oracle — keyed with the native
+ separator the source becomes `phases\\plan.md`, the prompt's `phases/plan.md`
+ reads undeclared, and a healthy install is refused on every story."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / "phases").mkdir()
+ (skill / "phases" / "plan.md").write_text("# plan\n", encoding="utf-8")
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "Read fully and follow: [[bmad-snapshot:phases/plan.md]]\n", encoding="utf-8"
+ )
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+ # The REPORTED rel is echoed from the token's own text, not derived from the
+ # source key, so it is POSIX on either platform under every ablation — this half
+ # pins the report's shape, not `as_posix()`. The assert above is the discriminator.
+ (skill / "phases" / "plan.md").unlink()
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].detail["missing_sources"] == ["phases/plan.md"]
+
+
+def test_an_undecodable_source_does_not_crash_the_preflight(tmp_path):
+ """A `.md` that is not UTF-8 is a HALT upstream too, but a different one
+ (`failed to read render source`) with a different remediation — so this gate
+ skips it rather than blaming it for an undeclared reference, the same fail-open
+ doctrine `_is_renderer_stub` and `_renderer_unit_required` follow.
+
+ What must not happen is the `UnicodeDecodeError` escaping into
+ `missing_base_skills`: a crashed `validate` names no file at all, which is
+ strictly worse than the finding it replaced. Cross-platform on purpose — no
+ chmod, so the Windows lane runs it too."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / "binary.md").write_bytes(b"\xff\xfe not utf-8\n")
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+ # …and it is still a DECLARED source: the name is readable even when the bytes
+ # are not, so a prompt naming it is not reported as pointing at nothing
+ (skill / RENDERER_ENTRY_REL).write_text(
+ "Read fully and follow: [[bmad-snapshot:binary.md]]\n", encoding="utf-8"
+ )
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_a_source_that_is_not_a_readable_file_is_not_a_source(tmp_path):
+ """Upstream requires each candidate to BE a file (`render source is missing or not
+ a file`) and this mirrors that, which is what keeps three failure shapes out at
+ once. A `workflow.md` that is a directory — the portable case, so Windows CI
+ witnesses it — HALTs upstream and must not read as present here.
+
+ The same conjunct is what stops a FIFO in the tree from blocking `read_text`
+ forever. That one has no test: making a preflight hang is a poor thing to ask CI
+ to reproduce, and `os.mkfifo` does not exist on Windows. It is named in the
+ helper's docstring instead, because an unbounded hang is the one failure mode
+ that is neither a false green nor a false refusal."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import RENDERER_ENTRY_REL, missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / RENDERER_ENTRY_REL).unlink()
+ (skill / RENDERER_ENTRY_REL).mkdir()
+
+ problems = missing_base_skills(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.dev-renderer-sources"]
+ assert problems[0].detail["missing_sources"] == [RENDERER_ENTRY_REL]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_an_unreadable_source_does_not_crash_the_preflight(tmp_path):
+ """The `OSError` half of the read guard, which the undecodable-bytes test cannot
+ reach — that one only ever raises `UnicodeDecodeError`. A chmod-000 `.md` passes
+ `is_file()` and then raises `PermissionError` from `read_text`; unguarded it
+ escapes into `missing_base_skills` and `validate` tracebacks instead of reporting,
+ which is strictly worse than the finding it replaced. POSIX-only, and it assumes
+ a non-root runner (root reads through mode 000)."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ locked = skill / "locked.md"
+ locked.write_text("# locked\n", encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ assert missing_base_skills(tmp_path, [tree]) == []
+ finally:
+ locked.chmod(0o644)
+
+
+def test_customization_prose_is_never_mined_for_snapshot_tokens(tmp_path):
+ """Upstream loads only `*.md` as sources and says so where it substitutes
+ ("Inserted paths and customization prose are never scanned as source tokens"), so
+ a token quoted inside `customize.toml`'s `instruction` blocks is left as literal
+ text and renders fine. Widening the source glob past `*.md` would mine that prose
+ and refuse every run of a healthy project — the same false positive a looser token
+ regex would cause, on a gate with no `--force`."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import missing_base_skills
+
+ tree = get_profile("claude").skill_tree
+ _install_hunters(tmp_path, tree)
+ _install_renderer_surface(tmp_path)
+ skill = install_build_auto_skill(tmp_path, tree, renderer_stub=True)
+ (skill / "customize.toml").write_text(
+ '[[review_layers]]\nid = "adversarial"\ninstruction = """\n'
+ "Do not read [[bmad-snapshot:step-99-not-a-source.md]] directly.\n"
+ '"""\n',
+ encoding="utf-8",
+ )
+
+ assert missing_base_skills(tmp_path, [tree]) == []
+
+
+def test_dev_primitive_warnings_flag_an_orphaned_legacy_customize_file(tmp_path):
+ """The rename does not migrate `_bmad/custom/.toml`, so an upgraded
+ project silently stops applying its overrides."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import CUSTOMIZE_DIR_REL, dev_primitive_warnings
+
+ tree = get_profile("claude").skill_tree
+ custom = tmp_path / CUSTOMIZE_DIR_REL
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8")
+
+ # a PRE-rename project: that file is the correct one — warning here would be
+ # noise on every legacy install (ablation: drop the resolved-is-new guard)
+ _install_legacy_primitive(tmp_path, tree)
+ assert dev_primitive_warnings(tmp_path, [tree]) == []
+
+ install_build_auto_skill(tmp_path, tree)
+ warnings = dev_primitive_warnings(tmp_path, [tree])
+ assert [f.check for f in warnings] == ["skills.customize-legacy"]
+ assert warnings[0].severity == "warning"
+ assert warnings[0].detail["files"] == [f"{CUSTOMIZE_DIR_REL}/bmad-dev-auto.toml"]
+
+ # a counterpart under the new name means the operator already migrated
+ (custom / "bmad-build-auto.toml").write_text("x\n", encoding="utf-8")
+ assert dev_primitive_warnings(tmp_path, [tree]) == []
+
+ # .user.toml is tracked separately — migrating one does not cover the other
+ (custom / "bmad-dev-auto.user.toml").write_text("x\n", encoding="utf-8")
+ warnings = dev_primitive_warnings(tmp_path, [tree])
+ assert warnings[0].detail["files"] == [f"{CUSTOMIZE_DIR_REL}/bmad-dev-auto.user.toml"]
+
+
+def test_dev_primitive_warnings_are_silent_when_nothing_resolves(tmp_path):
+ """missing_base_skills owns the unresolvable story; the warning must not pile
+ advisory noise on top of a hard preflight failure.
+
+ The customize site is armed here — an orphaned `bmad-dev-auto.toml` beside a shim
+ — so the silence is the resolution guard's doing and not merely the absence of
+ anything to say. The shim's SKILL.md is also a renderer stub, which pins that the
+ renderer probes moved out of this function entirely rather than merely going
+ quiet here (see test_renderer_checks_are_silent_when_nothing_resolves)."""
+ from conftest import RENDERER_STUB_SKILL_MD, install_dev_shim
+
+ from bmad_loop.install import CUSTOMIZE_DIR_REL, dev_primitive_warnings
+
+ tree = get_profile("claude").skill_tree
+ custom = tmp_path / CUSTOMIZE_DIR_REL
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8")
+ shim = install_dev_shim(tmp_path, tree)
+ (shim / "SKILL.md").write_text(RENDERER_STUB_SKILL_MD, encoding="utf-8")
+
+ assert dev_primitive_warnings(tmp_path, [tree]) == []
+
+
+def test_customize_legacy_warning_is_emitted_once_across_trees(tmp_path):
+ """`_bmad/custom/` is project-global, so two trees resolving to the new name
+ must not double-report the same orphaned file."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import CUSTOMIZE_DIR_REL, dev_primitive_warnings
+
+ claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_tree
+ custom = tmp_path / CUSTOMIZE_DIR_REL
+ custom.mkdir(parents=True, exist_ok=True)
+ (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8")
+ for tree in (claude, codex):
+ install_build_auto_skill(tmp_path, tree)
+
+ warnings = dev_primitive_warnings(tmp_path, [claude, codex])
+ assert [f.check for f in warnings] == ["skills.customize-legacy"]
+
+
+def test_missing_stories_support_probes_the_resolved_primitive(tmp_path):
+ """Stories mode's content probe follows resolution: a dispatch-capable
+ bmad-build-auto satisfies it even though the constant still names the legacy
+ skill, and a legacy step-01 beside it does NOT (that skill isn't driven)."""
+ from conftest import install_build_auto_skill
+
+ from bmad_loop.install import STORIES_PROBE_FILE, STORIES_PROBE_TEXT, missing_stories_support
+
+ tree = get_profile("claude").skill_tree
+ install_build_auto_skill(tmp_path, tree, folder_id=True)
+ assert missing_stories_support(tmp_path, [tree]) == []
+
+ # strip the marker from the RESOLVED skill and hide a good one in the legacy
+ # skill: the probe must report stale, not be satisfied by the wrong skill
+ (tmp_path / tree / "bmad-build-auto" / STORIES_PROBE_FILE).write_text("old\n")
+ legacy = _install_legacy_primitive(tmp_path, tree)
+ (legacy / STORIES_PROBE_FILE).write_text(f"**{STORIES_PROBE_TEXT}**\n", encoding="utf-8")
+
+ problems = missing_stories_support(tmp_path, [tree])
+ assert [f.check for f in problems] == ["skills.stories-dispatch-stale"]
+ assert problems[0].detail["skill"] == "bmad-build-auto"
+
- # the newest review layer (verification-gap) reported by name when absent
- _install_base_skills(tmp_path, claude.skill_tree) # re-complete everything
- import shutil as _shutil
+def test_provision_worktree_carries_the_new_primitives_subdirectories(tmp_path):
+ """Adding bmad-build-auto to BASE_SKILLS is what keeps isolation working across
+ the rename. The copy is a per-FILE merge that still walks into subdirectories, so
+ review-prompts/ (which customize.toml's layers point at) rides along — a
+ markers-only copy would leave every review layer unresolvable inside the
+ worktree."""
+ from conftest import install_build_auto_skill
- _shutil.rmtree(tmp_path / claude.skill_tree / "bmad-review-verification-gap")
- problems = missing_base_skills(tmp_path, [claude.skill_tree])
- assert len(problems) == 1
- assert "bmad-review-verification-gap" in problems[0].message
- assert "install the BMad Method" in problems[0].message
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ claude = get_profile("claude")
+ tree = claude.skill_tree
+ install_build_auto_skill(repo, tree)
+
+ provision_worktree(wt, [claude], repo)
+
+ assert (wt / tree / "bmad-build-auto" / "SKILL.md").is_file()
+ assert (wt / tree / "bmad-build-auto" / "customize.toml").is_file()
+ assert (wt / tree / "bmad-build-auto" / "review-prompts" / "adversarial.md").is_file()
def test_missing_stories_support_probes_step01_content(tmp_path):
@@ -677,8 +2493,10 @@ def test_missing_base_skills_findings_carry_ids_and_detail(tmp_path):
assert {f.check for f in absent} == {"skills.base-missing"}
assert all(f.severity == "problem" for f in absent)
assert all(f.check in VALIDATE_CHECKS for f in absent)
+ # the primitive is reported under the CURRENT upstream name — a fresh install
+ # follows the rename, so pointing an operator at bmad-dev-auto would misdirect
assert {f.detail["skill"] for f in absent} == {
- "bmad-dev-auto",
+ "bmad-build-auto",
"bmad-review-adversarial-general",
"bmad-review-edge-case-hunter",
"bmad-review-verification-gap",
@@ -686,8 +2504,8 @@ def test_missing_base_skills_findings_carry_ids_and_detail(tmp_path):
assert all(f.detail["tree"] == claude.skill_tree for f in absent)
_install_base_skills(tmp_path, claude.skill_tree)
- (tmp_path / claude.skill_tree / "bmad-dev-auto" / "step-04-review.md").unlink()
- (tmp_path / claude.skill_tree / "bmad-dev-auto" / "customize.toml").unlink()
+ (tmp_path / claude.skill_tree / "bmad-build-auto" / "step-04-review.md").unlink()
+ (tmp_path / claude.skill_tree / "bmad-build-auto" / "customize.toml").unlink()
incomplete = missing_base_skills(tmp_path, [claude.skill_tree])
assert len(incomplete) == 1
assert incomplete[0].check == "skills.base-incomplete"
@@ -749,23 +2567,26 @@ def test_missing_stories_support_reports_non_utf8_probe_without_crashing(tmp_pat
assert len(problems) == 1 and "not found" in problems[0].message
-def test_new_dev_auto_skill_is_additive_for_sprint_mode(tmp_path):
- """Scenario 6 additivity: installing the *new* bmad-dev-auto (folder+id
+@pytest.mark.parametrize("primitive", ["bmad-build-auto", "bmad-dev-auto"])
+def test_new_dev_auto_skill_is_additive_for_sprint_mode(tmp_path, primitive):
+ """Scenario 6 additivity: installing the *new* dev primitive (folder+id
dispatch present) satisfies both preflights — sprint mode's file-existence
check (`missing_base_skills`, which never inspects the dispatch content) and
stories mode's content probe (`missing_stories_support`). The new skill
- breaks neither pipeline."""
- from bmad_loop.install import (
- STORIES_PROBE_FILE,
- STORIES_PROBE_SKILL,
- missing_stories_support,
- )
+ breaks neither pipeline.
+
+ Parametrized over both upstream names because the stories probe reads the
+ RESOLVED primitive: writing the dispatch marker into the skill that did not
+ resolve must not satisfy it (and does not — the other leg would fail)."""
+ from bmad_loop.install import STORIES_PROBE_FILE, missing_stories_support
claude = get_profile("claude")
tree = claude.skill_tree
_install_base_skills(tmp_path, tree)
- # upgrade bmad-dev-auto in place to the folder+id dispatch version
- step01 = tmp_path / tree / STORIES_PROBE_SKILL / STORIES_PROBE_FILE
+ if primitive == "bmad-dev-auto":
+ shutil.rmtree(tmp_path / tree / "bmad-build-auto") # pre-rename project
+ # upgrade the resolved primitive in place to the folder+id dispatch version
+ step01 = tmp_path / tree / primitive / STORIES_PROBE_FILE
step01.write_text("route a **folder+id dispatch** invocation\n", encoding="utf-8")
# sprint mode (file existence) is unaffected by the new dispatch content …
@@ -819,15 +2640,20 @@ def test_provision_worktree_reports_seed_skipped_as_noop(tmp_path):
def test_provision_worktree_reports_seed_dir_skipped_whole(tmp_path):
"""The case that motivated the report: a worktree checks out tracked files, so a
seed DIRECTORY with any tracked child already exists and the whole entry is
- skipped — including children that are absent and would clobber nothing."""
+ skipped — including children that are absent and would clobber nothing.
+
+ Anchored on a NEUTRAL directory: this pins the generic `seed_files` contract,
+ which is unchanged. `_bmad` used to stand in for it and no longer can — that
+ one entry now gets a per-file merge of its own (see the _bmad seeding tests),
+ which is exactly the behaviour this test would otherwise be asserting away."""
wt, repo = tmp_path / "wt", tmp_path / "repo"
- (repo / "_bmad" / "custom").mkdir(parents=True) # tracked child
- (repo / "_bmad" / "bmm").mkdir() # gitignored sibling, absent from the checkout
- (repo / "_bmad" / "bmm" / "config.yaml").write_text("SEED ME", encoding="utf-8")
- (wt / "_bmad" / "custom").mkdir(parents=True) # what `git worktree add` lays down
+ (repo / "vendor" / "bin").mkdir(parents=True) # tracked child
+ (repo / "vendor" / "conf").mkdir() # gitignored sibling, absent from the checkout
+ (repo / "vendor" / "conf" / "tool.yaml").write_text("SEED ME", encoding="utf-8")
+ (wt / "vendor" / "bin").mkdir(parents=True) # what `git worktree add` lays down
- assert provision_worktree(wt, [], repo, seed_files=["_bmad"]) == ["_bmad"]
- assert not (wt / "_bmad" / "bmm").exists() # documents today's behaviour
+ assert provision_worktree(wt, [], repo, seed_files=["vendor"]) == ["vendor"]
+ assert not (wt / "vendor" / "conf").exists() # documents today's behaviour
def test_provision_worktree_reports_nothing_when_seeding_succeeds(tmp_path):
@@ -984,6 +2810,966 @@ def test_provision_worktree_seed_globs_shielded_in_local_exclude(project, tmp_pa
assert git(wt, "status", "--short", "--", ".claude/skills/tests-run") == ""
+# --------------------------------------------- seeding never writes through a link
+#
+# Both seed loops resolved the destination and then used the RESOLVED path for the
+# occupancy probe, the mkdir and the copy. `resolve()` is non-strict, so it follows a
+# dangling link to the path it names: the probe asks about the TARGET (absent ⇒ "free"),
+# and the copy then lands at a path nobody asked for. Measured on the two sibling
+# copiers' own guard: `_occupied(raw)` is True, `_occupied(raw.resolve())` is False —
+# which is why wiring `_occupied` in against the resolved path would have been a no-op
+# and the load-bearing change is splitting `raw` from `dst`.
+#
+# The refusal is a bare `continue`, matching both loops' existing contract: what was
+# dropped is named afterwards by `worktree_seed_undelivered`, which is also why these
+# tests assert the gate and not only the filesystem.
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_seed_never_writes_through_a_dangling_dst_symlink(tmp_path):
+ """T6a — the direct slot. `git worktree add` checks out a tracked symlink whose
+ target is untracked, so the worktree's `.mcp.json` IS a dangling link on the first
+ story of a project that keeps its configs that way. The seed then wrote the repo's
+ bytes to the link's target, an unrequested path inside the worktree that the
+ exclude does not name and `git add -A` would stage into the story branch."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ (repo / ".mcp.json").write_text("FROM_REPO", encoding="utf-8")
+ wt.mkdir()
+ stray = wt / "stray.json" # inside the worktree, so containment cannot refuse it
+ (wt / ".mcp.json").symlink_to(stray)
+ assert not stray.exists(), "the fixture must be DANGLING, not merely a symlink"
+
+ skipped = provision_worktree(wt, [], repo, seed_files=[".mcp.json"])
+
+ assert not stray.exists(), "seeded through the dangling link"
+ assert skipped == [] # not a skip: the destination never existed
+ assert worktree_seed_undelivered(wt, repo, seed_files=[".mcp.json"]) == [".mcp.json"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_seed_never_writes_through_a_dangling_parent(tmp_path):
+ """T6d — the other leg, and the reason the guard is `dst != raw` rather than
+ `_occupied(raw)`: here the leaf is not a link at all and does not exist, so
+ `_occupied` answers False. The DIRECTORY component is the dangling one, and
+ `mkdir(parents=True)` on the resolved path creates the target tree before the copy
+ lands in it."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / "vendor").mkdir(parents=True)
+ (repo / "vendor" / "conf.json").write_text("FROM_REPO", encoding="utf-8")
+ wt.mkdir()
+ stray_dir = wt / "elsewhere"
+ (wt / "vendor").symlink_to(stray_dir)
+ assert not stray_dir.exists()
+
+ provision_worktree(wt, [], repo, seed_files=["vendor/conf.json"])
+
+ assert not stray_dir.exists(), "mkdir + copy went through the dangling parent"
+ assert worktree_seed_undelivered(wt, repo, seed_files=["vendor/conf.json"]) == [
+ "vendor/conf.json"
+ ]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_seed_globs_never_write_through_a_dangling_dst_symlink(tmp_path):
+ """T6b — the glob loop carries the same fault and needs its own guard: it computes
+ its own `dst` from its own `rel` and shares no code with the `seed_files` loop."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ plugins = repo / "plugins"
+ plugins.mkdir(parents=True)
+ (plugins / "a.json").write_text("FROM_REPO", encoding="utf-8")
+ (wt / "plugins").mkdir(parents=True)
+ stray = wt / "stray.json"
+ (wt / "plugins" / "a.json").symlink_to(stray)
+ assert not stray.exists()
+
+ provision_worktree(wt, [], repo, seed_globs=["plugins/*.json"])
+
+ assert not stray.exists(), "the glob loop seeded through the dangling link"
+ assert worktree_seed_undelivered(wt, repo, seed_globs=["plugins/*.json"]) == ["plugins/a.json"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_a_live_symlinked_seed_destination_is_still_reported_as_skipped(tmp_path):
+ """T6ctrl, and the ORDERING constraint in one test. A worktree slot that is a link
+ to something real is an ordinary copy-when-absent no-op and belongs in `skipped`.
+ The refusal subsumes that case — every symlinked destination has `dst != raw`,
+ live or dangling — so placing it above the `dst.exists()` arm would convert the
+ whole no-op report into a silent drop. It goes strictly after."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ (repo / ".mcp.json").write_text("FROM_REPO", encoding="utf-8")
+ wt.mkdir()
+ real = wt / "real.json"
+ real.write_text("IN_WORKTREE", encoding="utf-8")
+ (wt / ".mcp.json").symlink_to(real)
+
+ assert provision_worktree(wt, [], repo, seed_files=[".mcp.json"]) == [".mcp.json"]
+ assert real.read_text(encoding="utf-8") == "IN_WORKTREE" # and nothing clobbered it
+
+
+# ----------------------------------------------------------------- dropped seeds (#415)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_names_a_symlinked_out_config(tmp_path):
+ """The headline fault, end to end: the repo carries `.mcp.json` as a symlink to a
+ shared checkout outside itself — a dotfile-managed config, an ordinary healthy
+ setup — so the seed loop's resolve-and-contain guard drops it with a bare
+ `continue` and provisioning reports nothing at all. `skipped` cannot carry this:
+ it reports only the destination-already-exists no-op.
+
+ `_is_file(src)`'s witness. The source side is deliberately NOT containment-checked,
+ so it stats True *through* the link — which is precisely the entry the loop
+ refused, and re-applying the loop's own guard here would make the gate agree with
+ it and report nothing."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ shared = tmp_path / "dotfiles" / "mcp.json"
+ shared.parent.mkdir()
+ shared.write_text('{"mcpServers": {}}', encoding="utf-8")
+ (repo / ".mcp.json").symlink_to(shared)
+
+ skipped = provision_worktree(wt, [], repo, seed_files=[".mcp.json"])
+
+ assert skipped == [] # the silence the report exists to break
+ assert not (wt / ".mcp.json").exists()
+ assert worktree_seed_undelivered(wt, repo, seed_files=[".mcp.json"]) == [".mcp.json"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_names_a_symlinked_out_directory(tmp_path):
+ """`_is_dir(src)`'s witness, and the reason the source filter is not `_is_file`
+ alone the way `_absent_skill_files`' is: a `worktree_seed` entry may name a
+ DIRECTORY as readily as a file — a plugin's tool tree pointing at a shared
+ install is the canonical shape — and the loop drops the whole entry."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ shared = tmp_path / "shared-tools"
+ shared.mkdir()
+ (shared / "tool.yaml").write_text("x\n", encoding="utf-8")
+ (repo / "vendor").symlink_to(shared, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [], repo, seed_files=["vendor"])
+
+ assert skipped == []
+ assert not (wt / "vendor").exists()
+ assert worktree_seed_undelivered(wt, repo, seed_files=["vendor"]) == ["vendor"]
+
+
+def test_worktree_seed_undelivered_is_silent_on_delivered_absent_and_present(tmp_path):
+ """The no-false-positive control: three entries fine in three different ways —
+ one the seed copied, one the repo simply does not have, one the checkout already
+ carried. Reporting any of them pauses nothing but journals a line per story for
+ every project with a routine seed list, which is how a report stops being read.
+
+ `_is_file(dst)`'s witness (both the delivered and the already-present legs land on
+ a FILE destination), and the two channels' separation: the already-present entry
+ belongs to `skipped` and must not appear in both."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ (repo / ".mcp.json").write_text("FROM_REPO", encoding="utf-8")
+ (repo / ".envrc").write_text("FROM_REPO", encoding="utf-8")
+ wt.mkdir()
+ (wt / ".envrc").write_text("IN_WORKTREE", encoding="utf-8")
+
+ rels = [".mcp.json", ".envrc", "absent.json"]
+ skipped = provision_worktree(wt, [], repo, seed_files=rels)
+
+ assert skipped == [".envrc"]
+ assert (wt / ".mcp.json").read_text() == "FROM_REPO"
+ assert worktree_seed_undelivered(wt, repo, seed_files=rels) == []
+
+
+def test_worktree_seed_undelivered_is_silent_on_a_delivered_directory(tmp_path):
+ """`_is_dir(dst)`'s witness: a directory entry that really did land is delivered,
+ and probing the destination with `_is_file` alone would call every one of them
+ undelivered — the loudest false positive available, since a seed dir is the shape
+ `worktree_seed` is most often used for."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / "vendor").mkdir(parents=True)
+ (repo / "vendor" / "tool.yaml").write_text("x\n", encoding="utf-8")
+
+ provision_worktree(wt, [], repo, seed_files=["vendor"])
+
+ assert (wt / "vendor" / "tool.yaml").is_file()
+ assert worktree_seed_undelivered(wt, repo, seed_files=["vendor"]) == []
+
+
+def test_worktree_seed_undelivered_names_an_entry_whose_destination_escapes(tmp_path):
+ """The destination containment conjunct, which exists for the OPPOSITE reason to
+ the source one: `worktree/../outside.txt` resolves out of the worktree onto the
+ very file the loop refused to write through, so an uncontained probe would stat
+ it, read it as delivered, and go quiet on the one entry that provably copied
+ nothing (`test_provision_worktree_seed_rejects_escaping_path`).
+
+ The worktree directory has to EXIST for the ablation to be visible: a `..`
+ component under a path that is not there fails to resolve on POSIX and reads as
+ absent either way, which would hide the difference the conjunct makes."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ wt.mkdir()
+ (tmp_path / "outside.txt").write_text("SECRET", encoding="utf-8")
+
+ provision_worktree(wt, [], repo, seed_files=["../outside.txt"])
+
+ assert list(wt.iterdir()) == [] # nothing copied
+ assert worktree_seed_undelivered(wt, repo, seed_files=["../outside.txt"]) == ["../outside.txt"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_stays_silent_on_a_dangling_repo_symlink(tmp_path):
+ """The source filter's reverse direction, mirroring
+ `test_a_dangling_repo_symlink_is_dropped_by_the_copier_and_the_gate`: a broken
+ link is neither file nor directory, so both halves drop it — the copier has no
+ bytes to deliver and the gate has nothing to demand. Without the filter every such
+ link becomes a journal line on every story, over a file the operator's own repo
+ cannot produce either."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ repo.mkdir()
+ (repo / ".mcp.json").symlink_to(repo / "never-installed.json")
+
+ skipped = provision_worktree(wt, [], repo, seed_files=[".mcp.json"])
+
+ assert skipped == []
+ assert worktree_seed_undelivered(wt, repo, seed_files=[".mcp.json"]) == []
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_worktree_seed_undelivered_names_an_unreadable_source(tmp_path):
+ """Absent-from-the-repo and unreadable-in-the-repo must not collapse into one
+ silence — the same split `base_skills_seed_incomplete` makes. `_is_dir` reads a
+ refused probe as a directory on every interpreter, so the entry is carried and the
+ gate names it.
+
+ Called DIRECTLY, and that is not shorthand: an end-to-end fixture cannot express
+ this claim, because the two lanes disagree on the step BEFORE the gate. Through
+ 3.13 the loop's own `src.exists()` raises `PermissionError` out of
+ `provision_worktree`; on 3.14 it answers False and drops the entry silently. Only
+ the gate itself reads the same on both.
+
+ POSIX-only, non-root runner (root reads through mode 000)."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ locked = repo / "conf"
+ locked.mkdir(parents=True)
+ (locked / "tool.yaml").write_text("x\n", encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ undelivered = worktree_seed_undelivered(wt, repo, seed_files=["conf/tool.yaml"])
+ finally:
+ locked.chmod(0o755)
+
+ assert undelivered == ["conf/tool.yaml"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_names_a_symlinked_out_glob_match(tmp_path):
+ """The glob leg — re-expanded here rather than handed in, so the answer stays a
+ pure function of the two trees on disk and a caller cannot forge one. A plugin's
+ `seed_globs` hits a tool dir the repo carries as a link to a shared install; the
+ loop drops that match and copies its siblings. Named as a POSIX rel, matching what
+ the loop itself puts in the git exclude."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ skills = repo / ".claude" / "skills"
+ (skills / "local").mkdir(parents=True)
+ (skills / "local" / "SKILL.md").write_text("tool", encoding="utf-8")
+ shared = tmp_path / "shared-install" / "scene-open"
+ shared.mkdir(parents=True)
+ (shared / "SKILL.md").write_text("tool", encoding="utf-8")
+ (skills / "scene-open").symlink_to(shared, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [], repo, seed_globs=[".claude/skills/*"])
+
+ assert skipped == []
+ assert (wt / ".claude" / "skills" / "local" / "SKILL.md").is_file() # the rest landed
+ assert worktree_seed_undelivered(wt, repo, seed_globs=[".claude/skills/*"]) == [
+ ".claude/skills/scene-open"
+ ]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_reports_a_doubly_reachable_rel_once(tmp_path):
+ """A rel a plugin's glob and a user's `worktree_seed` both name is one fact and
+ one journal line — deduped over the concatenated rels, before any probe, so the
+ two spellings cannot each earn an entry."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / "vendor").mkdir(parents=True)
+ (tmp_path / "outside.toml").write_text("x\n", encoding="utf-8")
+ (repo / "vendor" / "conf.toml").symlink_to(tmp_path / "outside.toml")
+
+ undelivered = worktree_seed_undelivered(
+ wt, repo, seed_files=["vendor/conf.toml"], seed_globs=["vendor/*"]
+ )
+
+ assert undelivered == ["vendor/conf.toml"]
+
+
+# ------------------------------------------------- dropped seeds: the hook configs (#405)
+#
+# The destination probe answers for every seed rel EXCEPT the one the hook-registration
+# step writes itself. That step runs after both seed loops and creates
+# `profile.hooks.config_path` unconditionally, so for that rel a present destination is
+# the hook's own bytes and says nothing about the seed. Every hook config is also a
+# profile seed entry — and for gemini, copilot and antigravity it is the profile's SOLE
+# default seed, so the silence is the gate's entire answer for those CLIs.
+#
+# Measured end to end through the real `provision_worktree` before these were written:
+# with the repo's `.claude/settings.json` symlinked out, `skipped == []`, the worktree's
+# copy exists and carries hook keys but not the repo's, and the gate returned `[]`.
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+@pytest.mark.parametrize("profile_name", ["claude", "gemini"])
+def test_worktree_seed_undelivered_names_a_symlinked_out_hook_config(tmp_path, profile_name):
+ """T8a — the headline false green. The repo carries its CLI settings as a symlink to
+ a dotfile checkout outside itself, so the seed loop's source-containment guard drops
+ it; the hook step then writes that same path with a hooks-only config, and the
+ destination probe reads that as delivered.
+
+ Parametrized because the fault is not claude-shaped: gemini's `config_path` IS its
+ only default seed entry, so for it the gate goes from "reports nothing about this
+ rel" to "reports nothing at all"."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ profile = get_profile(profile_name)
+ rel = profile.hooks.config_path
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / rel).parent.mkdir(parents=True)
+ shared = tmp_path / "dotfiles" / "settings.json"
+ shared.parent.mkdir()
+ shared.write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ (repo / rel).symlink_to(shared)
+ wt.mkdir()
+
+ skipped = provision_worktree(wt, [profile], repo, seed_files=[rel])
+
+ # the seed delivered nothing and said nothing; the hook step wrote the path anyway
+ assert skipped == []
+ assert (wt / rel).is_file()
+ assert "FROM_THE_REPO" not in (wt / rel).read_text(encoding="utf-8")
+ # ...which is exactly what blinds the destination probe when it is not told
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel]) == []
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel], config_paths=[rel]) == [rel]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_worktree_seed_undelivered_names_a_hook_config_written_through_a_link(tmp_path):
+ """T8c — the other leg, and the one the source arm cannot reach: the SOURCE is an
+ ordinary contained file and the worktree's slot is a dangling symlink (a tracked
+ link whose target is untracked, so `git worktree add` lays it down broken). The seed
+ loop now refuses to write through it; the hook step has no such guard and
+ materialises the target, so the probe stats a real file through the link."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ profile = get_profile("claude")
+ rel = profile.hooks.config_path
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / rel).parent.mkdir(parents=True)
+ (repo / rel).write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ (wt / ".claude").mkdir(parents=True)
+ stray = wt / "stray.json" # inside the worktree, so containment cannot refuse it
+ (wt / rel).symlink_to(stray)
+ assert not stray.exists(), "the fixture must be DANGLING, not merely a symlink"
+
+ provision_worktree(wt, [profile], repo, seed_files=[rel])
+
+ assert stray.is_file(), "the hook step is expected to write through — that is the point"
+ assert "FROM_THE_REPO" not in stray.read_text(encoding="utf-8")
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel]) == []
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel], config_paths=[rel]) == [rel]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_a_hook_config_symlinked_out_of_the_worktree_is_named_once(tmp_path):
+ """T8b — the branch REPLACES the destination probe rather than adding to it. This
+ destination trips both halves at once (it is a symlink AND it resolves outside), so
+ a branch that fell through to the probe would earn the rel a second entry. One fact,
+ one journal line — the contract
+ `test_worktree_seed_undelivered_reports_a_doubly_reachable_rel_once` sets."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ profile = get_profile("claude")
+ rel = profile.hooks.config_path
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / rel).parent.mkdir(parents=True)
+ (repo / rel).write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ (wt / ".claude").mkdir(parents=True)
+ outside = tmp_path / "elsewhere.json"
+ (wt / rel).symlink_to(outside)
+
+ provision_worktree(wt, [profile], repo, seed_files=[rel])
+
+ assert outside.is_file() # the hook step wrote clean out of the worktree
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel], config_paths=[rel]) == [rel]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_a_hook_config_under_a_symlinked_out_parent_is_still_named(tmp_path):
+ """T8f — the containment arm, and why the branch keeps one rather than asking
+ `is_symlink()` alone. Here `.claude/` is the link and it points out of the worktree,
+ so the LEAF is not a symlink and the source is perfectly contained: both of the
+ other arms answer "delivered". The ordinary probe already named this case, so
+ dropping containment when the branch took over would have been a regression, not a
+ gap — which is why this test is GREEN on the unfixed build."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ profile = get_profile("claude")
+ rel = profile.hooks.config_path
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ (repo / rel).parent.mkdir(parents=True)
+ (repo / rel).write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ outdir = tmp_path / "outdir"
+ outdir.mkdir()
+ wt.mkdir()
+ (wt / ".claude").symlink_to(outdir, target_is_directory=True)
+
+ provision_worktree(wt, [profile], repo, seed_files=[rel])
+
+ assert (outdir / "settings.json").is_file()
+ assert not (wt / rel).is_symlink() # the leaf is a real file; only the parent links
+ assert worktree_seed_undelivered(wt, repo, seed_files=[rel], config_paths=[rel]) == [rel]
+
+
+def test_worktree_seed_undelivered_is_silent_on_hook_configs_that_are_fine(tmp_path):
+ """T8ctrl — the no-false-positive control for the branch, both ways a hook config can
+ be legitimately quiet: claude's is delivered by the seed and then merged into, and
+ gemini's was already in the checkout so the entry was a no-op that belongs to
+ `skipped` alone. Reporting either would put a line per story into the channel for
+ every project that seeds its CLI settings, which is how a report stops being read."""
+ from bmad_loop.install import worktree_seed_undelivered
+
+ claude, gemini = get_profile("claude"), get_profile("gemini")
+ rels = [claude.hooks.config_path, gemini.hooks.config_path]
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ for rel in rels:
+ (repo / rel).parent.mkdir(parents=True, exist_ok=True)
+ (repo / rel).write_text('{"env": {"FROM_THE_REPO": "1"}}', encoding="utf-8")
+ (wt / rels[1]).parent.mkdir(parents=True)
+ (wt / rels[1]).write_text('{"env": {"FROM_THE_CHECKOUT": "1"}}', encoding="utf-8")
+
+ skipped = provision_worktree(wt, [claude, gemini], repo, seed_files=rels)
+
+ assert skipped == [rels[1]]
+ assert "FROM_THE_REPO" in (wt / rels[0]).read_text(encoding="utf-8")
+ assert "FROM_THE_CHECKOUT" in (wt / rels[1]).read_text(encoding="utf-8")
+ assert worktree_seed_undelivered(wt, repo, seed_files=rels, config_paths=rels) == []
+
+
+# ----------------------------------------------------------------- _bmad/ config surface
+
+
+def _write_bmad_surface(repo):
+ """The minimum renderer-era `_bmad/` a project carries: the central config plus
+ the two-file script unit (render_skill.py bare-imports config_utils off
+ sys.path[0], so a partial seed loses even the `HALT:` contract line)."""
+ (repo / "_bmad" / "scripts").mkdir(parents=True)
+ (repo / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (repo / "_bmad" / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (repo / "_bmad" / "scripts" / "config_utils.py").write_text("# config", encoding="utf-8")
+
+
+def test_provision_worktree_seeds_bmad_when_absent(tmp_path):
+ """A worktree checks out tracked files only, so a gitignored `_bmad/` is missing
+ from it — and the renderer is handed the worktree as its project root and hard
+ fails when that root has no `_bmad/`. Seed the whole surface in."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (repo / "_bmad" / "custom" / "nested").mkdir(parents=True)
+ (repo / "_bmad" / "custom" / "nested" / "over.toml").write_text("x", encoding="utf-8")
+
+ assert provision_worktree(wt, [], repo) == []
+
+ assert (wt / "_bmad" / "config.toml").read_text() == "[core]\n"
+ # the whole scripts unit, not a curated file: the sibling import must resolve
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").is_file()
+ assert (wt / "_bmad" / "scripts" / "config_utils.py").is_file()
+ assert (wt / "_bmad" / "custom" / "nested" / "over.toml").read_text() == "x"
+
+
+def test_provision_worktree_bmad_merge_fills_only_missing_files(tmp_path):
+ """Per-FILE merge into a checkout that COMMITS its `_bmad/`: the tracked files
+ are left byte-identical (nothing new merges back) and only the gitignored
+ layers are filled in. A whole-dir copy-when-absent would skip the lot."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (repo / "_bmad" / "config.user.toml").write_text("USER_LAYER", encoding="utf-8")
+ # what `git worktree add` lays down: the tracked half only
+ (wt / "_bmad" / "scripts").mkdir(parents=True)
+ (wt / "_bmad" / "config.toml").write_text("COMMITTED", encoding="utf-8")
+ (wt / "_bmad" / "scripts" / "render_skill.py").write_text("COMMITTED", encoding="utf-8")
+
+ provision_worktree(wt, [], repo)
+
+ assert (wt / "_bmad" / "config.toml").read_text() == "COMMITTED"
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").read_text() == "COMMITTED"
+ # the gitignored layer + the untracked sibling script were filled in
+ assert (wt / "_bmad" / "config.user.toml").read_text() == "USER_LAYER"
+ assert (wt / "_bmad" / "scripts" / "config_utils.py").read_text() == "# config"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_seeds_a_symlinked_bmad_subdir(tmp_path):
+ """F2: `_bmad/` gets the skill trees' walk semantics. `rglob` does not descend a
+ symlinked SUB-directory, which is how a shared BMAD install is commonly wired one
+ level in (`_bmad/scripts/lib -> …`), so the whole subtree was copied as nothing —
+ and `_bmad_scripts_seed_incomplete`, mirroring the same rglob, agreed nothing was
+ missing. The under-seed and its own detector were wrong together, which is why both
+ moved to the shared walk in one change rather than one at a time.
+
+ The link points INSIDE the repo, which is the case the change actually reverses:
+ one pointing out is still dropped file-by-file by the containment guard — but the
+ gate can now SEE it, which the sibling test pins."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ real = repo / "shared-lib"
+ real.mkdir()
+ (real / "helper.py").write_text("# helper", encoding="utf-8")
+ (repo / "_bmad" / "scripts" / "lib").symlink_to(real, target_is_directory=True)
+
+ assert provision_worktree(wt, [], repo) == []
+
+ assert (wt / "_bmad" / "scripts" / "lib" / "helper.py").read_text() == "# helper"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_reports_a_symlinked_out_scripts_subdir(tmp_path):
+ """The detector half of the same change. A `_bmad/scripts/lib` pointed at a shared
+ install outside the repo is dropped file-by-file by the containment guard, exactly
+ as before — what changes is that the gate now walks the same way the seed does and
+ can see the files that were dropped.
+
+ Under the old rglob mirror the directory was yielded but never descended, so its
+ `is_file()` was False, the filter dropped it, and two rglobs agreed the seed was
+ complete. Two rglobs agreeing is not a check. A partial `_bmad/scripts/` is worse
+ than none: the bare `config_utils` import fails above the renderer's try/except and
+ even the `HALT:` line is lost."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "helper.py").write_text("# helper", encoding="utf-8")
+ (repo / "_bmad" / "scripts" / "lib").symlink_to(shared, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [], repo)
+
+ assert skipped == ["_bmad/scripts"]
+ assert not (wt / "_bmad" / "scripts" / "lib").exists()
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_reports_an_unreadable_scripts_subdir(tmp_path):
+ """The `is_dir()` half of the scripts gate's source filter — the same predicate
+ `_absent_skill_files` carries, on the other seed leg.
+
+ C4, the silent one: a repo `_bmad/scripts/lib` that is simply unreadable was copied
+ as nothing AND reported complete, both halves on `rglob`, wrong together. The seed
+ cannot deliver a directory it cannot list, and this must not stay quiet about it.
+ POSIX-only, and it assumes a non-root runner."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ locked = repo / "_bmad" / "scripts" / "lib"
+ locked.mkdir()
+ (locked / "helper.py").write_text("# helper", encoding="utf-8")
+ locked.chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [], repo)
+ finally:
+ locked.chmod(0o755)
+
+ assert skipped == ["_bmad/scripts"]
+ assert not (wt / "_bmad" / "scripts" / "lib").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unreadable_bmad_root(tmp_path):
+ """The one `iterdir` the shared walk does not own — `_seed_bmad_tree` lists the
+ `_bmad/` root itself, before the walk starts, to apply `BMAD_SEED_EXCLUDES` — so it
+ needs its own `try`.
+
+ Returning `[]` is the same doctrine as the leaf below it, and safe for the same
+ reason the sibling scripts predicate goes quiet on this fault: no run reaches
+ provisioning with a `_bmad/` it cannot read, because the run-start preflight sees it
+ two files in (`render_skill.py` is not a readable file either). ⚠️ Measured, the
+ preflight says so in two shapes — `skills.dev-renderer` + `-config` on 3.14, a
+ traceback out of `validate`/`run` through 3.13. Loud either way; only provisioning
+ had to be made total, and this pins that it is.
+
+ The central-config predicate is in the blast radius and is asserted here rather than
+ separately: its bare `is_file()` raised straight back out of `provision_worktree`
+ two calls after the `try` that prevented the same crash. POSIX-only, non-root."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (repo / "_bmad").chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [], repo)
+ finally:
+ (repo / "_bmad").chmod(0o755)
+
+ assert skipped == []
+ assert not (wt / "_bmad").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
+def test_provision_worktree_survives_an_unreadable_bmad_file(tmp_path):
+ """The leaf `try` on this leg: one unreadable file under `_bmad/` is a per-file
+ skip, not the end of the run — and every sibling still lands.
+
+ The file is reported through the channel that already existed for it, so the
+ degradation is visible: `_central_config_seed_incomplete` re-asks the RESULT and
+ finds the worktree without a `config.toml`, which is a `required=True` layer for
+ `load_central_config` and would HALT every story. POSIX-only, non-root runner."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ locked = repo / "_bmad" / "config.toml"
+ locked.chmod(0o000)
+ try:
+ skipped = provision_worktree(wt, [], repo)
+ finally:
+ locked.chmod(0o644)
+
+ assert skipped == ["_bmad/config.toml"]
+ assert not (wt / "_bmad" / "config.toml").exists()
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").is_file() # the rest landed
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_never_writes_through_a_dangling_bmad_symlink(tmp_path):
+ """`_occupied` on the `_bmad/` leg, where the checkout genuinely commits its
+ `_bmad/` surface and can therefore check out a symlink whose target this machine
+ does not have.
+
+ Writing through it lands `config.toml`'s bytes at the link's target and leaves the
+ completeness gate reading green through the now-live link — a false green on the one
+ file `load_central_config` requires. Skipping turns that into an honest
+ `_bmad/config.toml` in `skipped`, which the engine already knows how to escalate."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (wt / "_bmad").mkdir(parents=True)
+ elsewhere = wt / "_bmad" / "not-checked-out.toml"
+ (wt / "_bmad" / "config.toml").symlink_to(elsewhere)
+
+ skipped = provision_worktree(wt, [], repo)
+
+ assert not elsewhere.exists() # the bytes never landed at the link's target
+ assert skipped == ["_bmad/config.toml"]
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_bmad_merge_refuses_a_symlinked_out_destination(tmp_path):
+ """The DESTINATION containment half of the `_bmad/` merge, alone — and until this
+ test it was carried by no row in the suite (#405).
+
+ Its two siblings each arm a different clause: the escape test above arms the
+ SOURCE guard, and the dangling-link test arms `_occupied`, because a dangling
+ symlink is occupied by definition. Neither can reach destination containment, so
+ deleting that clause left both of them green.
+
+ This link is at the `_bmad/` root and it is LIVE, which is the combination that
+ isolates the clause: every target beneath it is absent and unoccupied, and the
+ source is an ordinary in-repo file. The merge walks the repo's whole surface, so
+ without containment it writes the project's central config and its renderer scripts
+ into a directory outside the worktree — one nothing here created, nothing excludes,
+ and no teardown ever cleans.
+
+ The refusal is reported, not silent: the worktree's `_bmad/` is then short of both
+ renderer sentinels, and the completeness checks name them."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ wt.mkdir()
+ (wt / "_bmad").symlink_to(outside, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [], repo)
+
+ assert list(outside.iterdir()) == [] # the repo's `_bmad/` went nowhere
+ assert skipped == ["_bmad/scripts", "_bmad/config.toml"]
+
+
+def test_provision_worktree_never_seeds_render_output(tmp_path):
+ """`_bmad/render/` is the renderer's published output, keyed on a hash of the
+ project root's absolute path — seeding the main checkout's copy would carry ITS
+ paths in. Excluded before descending, so a huge render/ is never even walked."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ snapshot = repo / "_bmad" / "render" / "bmad-build-auto" / "repo-abc123" / "deadbeef"
+ snapshot.mkdir(parents=True)
+ (snapshot / "workflow.md").write_text("STALE", encoding="utf-8")
+
+ provision_worktree(wt, [], repo)
+
+ assert (wt / "_bmad" / "config.toml").is_file() # the rest still seeded
+ assert not (wt / "_bmad" / "render").exists()
+
+
+def test_provision_worktree_shields_whole_bmad_when_worktree_lacked_it(project, tmp_path):
+ """The worktree had no `_bmad/`, so everything under it is ours: shield the ROOT
+ with one `/_bmad` line rather than each seeded file.
+
+ Asserting the exact line matters — `git status` comes back clean either way
+ (per-file lines cover today's files), but the root line is what also shields the
+ files a session's renderer/config writes create AFTER provisioning. That last
+ claim is witnessed next door, in
+ `test_provision_worktree_render_shield_subsumed_by_the_root_line`, which is also
+ where the `/_bmad/render/` line's absence here is accounted for."""
+ repo = project.project
+ _write_bmad_surface(repo)
+ wt = tmp_path / "wt"
+ verify.worktree_add(repo, wt, "feat", "main")
+
+ provision_worktree(wt, [get_profile("claude")], repo)
+
+ exclude = (repo / ".git" / "info" / "exclude").read_text(encoding="utf-8").splitlines()
+ assert "/_bmad" in exclude
+ assert not [line for line in exclude if line.startswith("/_bmad/scripts")]
+ assert git(wt, "status", "--short", "--", "_bmad") == ""
+
+
+def test_provision_worktree_shields_only_seeded_files_when_bmad_committed(project, tmp_path):
+ """A checkout that COMMITS its `_bmad/` gets the individual seeded rels — shield
+ exactly what we wrote. A blanket `/_bmad` would hide the project's own tracked
+ config surface from the unit's `git add -A` for the rest of the run."""
+ repo = project.project
+ _write_bmad_surface(repo)
+ (repo / "_bmad" / "config.user.toml").write_text("USER_LAYER", encoding="utf-8")
+ git(repo, "add", "-A", "--", "_bmad/config.toml", "_bmad/scripts")
+ git(repo, "commit", "-q", "-m", "commit the _bmad surface")
+ wt = tmp_path / "wt"
+ verify.worktree_add(repo, wt, "feat", "main")
+
+ provision_worktree(wt, [get_profile("claude")], repo)
+
+ exclude = (repo / ".git" / "info" / "exclude").read_text(encoding="utf-8").splitlines()
+ assert "/_bmad/config.user.toml" in exclude
+ assert "/_bmad" not in exclude
+ assert git(wt, "status", "--short", "--", "_bmad") == ""
+
+
+def test_provision_worktree_render_shield_only_when_bmad_present(project, tmp_path):
+ """`/_bmad/render/` shields the dir the renderer rewrites mid-session, long after
+ provisioning — where nothing else already does. Both of its conditions exist to
+ keep lines OUT of `.git/info/exclude`, which is the COMMON git dir a linked
+ worktree shares with the main checkout and which nothing here ever prunes.
+
+ Case 1: a bare worktree that can never grow a `_bmad/`, so the line would be
+ permanent pollution in the operator's own repo for a dir that will never exist.
+ Case 2: a checkout that COMMITS its `_bmad/`, so the blanket root line is not
+ written and this line is the only thing standing between the renderer's output and
+ the unit's `git add -A`. The third case — worktree lacked it, root line goes in —
+ is the sibling below."""
+ repo = project.project
+ wt_bare = tmp_path / "wt-bare"
+ verify.worktree_add(repo, wt_bare, "bare", "main")
+
+ provision_worktree(wt_bare, [get_profile("claude")], repo)
+
+ exclude_path = repo / ".git" / "info" / "exclude"
+ assert "/_bmad/render/" not in exclude_path.read_text(encoding="utf-8").splitlines()
+
+ _write_bmad_surface(repo)
+ git(repo, "add", "-A", "--", "_bmad")
+ git(repo, "commit", "-q", "-m", "commit the _bmad surface")
+ wt = tmp_path / "wt"
+ verify.worktree_add(repo, wt, "feat", "main")
+
+ provision_worktree(wt, [get_profile("claude")], repo)
+
+ exclude = exclude_path.read_text(encoding="utf-8").splitlines()
+ assert "/_bmad" not in exclude # the checkout carries its own, so nothing blankets it
+ assert "/_bmad/render/" in exclude
+
+
+def test_provision_worktree_render_shield_subsumed_by_the_root_line(project, tmp_path):
+ """The third case, and the one that makes the pair one policy rather than two
+ conditions: the worktree lacked `_bmad/`, so the blanket `/_bmad` root line goes
+ in — and a `/_bmad/render/` line beneath it would never be consulted. Measured
+ with `git check-ignore -v`: `/_bmad` prunes the directory before git descends into
+ it, so both lines attribute to the root one. Writing both was the inconsistency —
+ one sibling gated to avoid polluting a shared file while the other added a
+ provably inert line to it.
+
+ The first assert is the false-positive control, and the witness
+ `test_provision_worktree_shields_whole_bmad_when_worktree_lacked_it` claims but
+ does not make: a renderer write landing AFTER provisioning is still shielded, by
+ the root line alone. Order matters — pytest stops at the first failure, so under an
+ ablation of the subsumption conjunct this one must be read as still GREEN and only
+ the line's absence may move."""
+ repo = project.project
+ _write_bmad_surface(repo)
+ wt = tmp_path / "wt"
+ verify.worktree_add(repo, wt, "feat", "main")
+
+ provision_worktree(wt, [get_profile("claude")], repo)
+
+ # what the renderer writes mid-session, long after provisioning ran
+ snapshot = wt / "_bmad" / "render" / "bmad-build-auto" / "repo-abc123" / "deadbeef"
+ snapshot.mkdir(parents=True)
+ (snapshot / "workflow.md").write_text("RENDERED", encoding="utf-8")
+ assert git(wt, "status", "--short", "--", "_bmad") == ""
+
+ exclude = (repo / ".git" / "info" / "exclude").read_text(encoding="utf-8").splitlines()
+ assert "/_bmad" in exclude
+ assert "/_bmad/render/" not in exclude
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_bmad_seed_rejects_symlink_escape(tmp_path):
+ """A `_bmad/` entry resolving outside the repo is never copied in — the same
+ resolve-and-contain guard the seed_files loop uses. Whole-tree seeding is
+ exactly where an unguarded walk would drag a shared install's files across."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (tmp_path / "outside").mkdir()
+ (tmp_path / "outside" / "secret.toml").write_text("SECRET", encoding="utf-8")
+ (repo / "_bmad" / "escape.toml").symlink_to(tmp_path / "outside" / "secret.toml")
+
+ provision_worktree(wt, [], repo)
+
+ assert (wt / "_bmad" / "config.toml").is_file() # the contained files still seeded
+ assert not (wt / "_bmad" / "escape.toml").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_reports_bmad_scripts_not_seeded(tmp_path):
+ """A SYMLINKED `_bmad/scripts/` (how a shared BMAD install is wired) resolves
+ outside the repo, so the contain guard drops every file under it and the dir
+ lands EMPTY while provisioning otherwise reports success. The post-seed
+ completeness guard turns that into a reported skip the caller journals."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ shared = tmp_path / "shared-bmad" / "scripts"
+ shared.mkdir(parents=True)
+ (shared / "render_skill.py").write_text("# render", encoding="utf-8")
+ (shared / "config_utils.py").write_text("# config", encoding="utf-8")
+ (repo / "_bmad").mkdir(parents=True)
+ (repo / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (repo / "_bmad" / "scripts").symlink_to(shared, target_is_directory=True)
+
+ skipped = provision_worktree(wt, [], repo)
+
+ assert skipped == ["_bmad/scripts"]
+ assert (wt / "_bmad" / "config.toml").is_file() # the rest looked like success
+ assert not (wt / "_bmad" / "scripts" / "render_skill.py").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
+def test_provision_worktree_reports_a_dropped_central_config(tmp_path):
+ """The config leg of the same completeness check, and the case the scripts leg
+ cannot reach: `_bmad/scripts/` is a REAL directory and seeds whole, while
+ `_bmad/config.toml` alone is symlinked out of the repo (a shared BMAD install
+ where only the config is centralised). The contain guard drops it with a bare
+ `continue`, `_seed_bmad_tree` still returns "the whole tree is ours", and the
+ repo-side preflight follows the symlink and passes — so without this leg the
+ run is 100% silent and burns the entire backlog on result-less Stops."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ shared = tmp_path / "shared-bmad"
+ shared.mkdir()
+ (shared / "config.toml").write_text("[core]\n", encoding="utf-8")
+ (repo / "_bmad" / "scripts").mkdir(parents=True)
+ (repo / "_bmad" / "scripts" / "render_skill.py").write_text("# render", encoding="utf-8")
+ (repo / "_bmad" / "scripts" / "config_utils.py").write_text("# config", encoding="utf-8")
+ (repo / "_bmad" / "config.toml").symlink_to(shared / "config.toml")
+ # the repo-side probe follows the symlink, which is why the preflight passed
+ assert (repo / "_bmad" / "config.toml").is_file()
+
+ skipped = provision_worktree(wt, [], repo)
+
+ assert skipped == ["_bmad/config.toml"]
+ assert not (wt / "_bmad" / "config.toml").exists()
+ # …while the scripts unit landed whole, so the scripts leg says nothing
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").is_file()
+ assert (wt / "_bmad" / "scripts" / "config_utils.py").is_file()
+
+
+def test_provision_worktree_central_config_check_asks_is_file_not_exists(tmp_path):
+ """The same leg, armed symlink-free so it runs on WINDOWS — where the sentinel
+ path has never executed once, and where this PR already shipped a
+ POSIX-invisible bug. Copy-when-absent only asks `dst.exists()`, so a destination
+ occupied by anything that is not a file leaves the worktree without a readable
+ config while the seed reports success. Also pins the worktree probe to
+ `is_file()`: relaxing it to `exists()` would call this worktree healthy."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (wt / "_bmad" / "config.toml").mkdir(parents=True)
+
+ assert provision_worktree(wt, [], repo) == ["_bmad/config.toml"]
+
+ # …and it clears the moment the worktree really carries the file
+ wt2 = tmp_path / "wt2"
+ assert provision_worktree(wt2, [], repo) == []
+ assert (wt2 / "_bmad" / "config.toml").is_file()
+
+
+def test_provision_worktree_never_lets_a_seed_entry_forge_a_sentinel(tmp_path):
+ """`RENDERER_SEED_SENTINELS` are the completeness checks' to emit, never a
+ user's to forge. A `worktree_seed` entry spelling one exactly — both are natural
+ things to write, and 0.9.0's docs taught the `_bmad` family — lands in `skipped`
+ the moment the checkout already carries the path, and the `_is_under_bmad` strip
+ above is conditional on the merge having seeded SOMETHING, which a checkout that
+ commits its whole `_bmad/` never does. The engine would then read a forged
+ sentinel and CRITICAL-escalate a completely healthy worktree."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ _write_bmad_surface(wt) # a checkout that commits its whole _bmad/: merge is a no-op
+
+ skipped = provision_worktree(
+ wt, [], repo, seed_files=["_bmad/scripts", "_bmad/config.toml", "vendor"]
+ )
+
+ assert skipped == []
+ # the worktree really is healthy — neither check has anything to say about it
+ assert (wt / "_bmad" / "config.toml").is_file()
+ assert (wt / "_bmad" / "scripts" / "config_utils.py").is_file()
+
+
+def test_provision_worktree_bmad_merge_unreports_the_documented_seed_workaround(tmp_path):
+ """0.9.0 documented `worktree_seed = ["_bmad"]` as the workaround for this gap.
+ That entry is now a no-op BECAUSE the merge already covered it, so reporting it
+ would journal worktree-seed-skipped for a seed that did apply."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+ (wt / "_bmad").mkdir(parents=True) # tracked child makes the whole entry a no-op
+
+ assert provision_worktree(wt, [], repo, seed_files=["_bmad", ".mcp.json"]) == []
+ assert (wt / "_bmad" / "scripts" / "config_utils.py").is_file()
+
+
+def test_provision_worktree_empty_profiles_still_seeds_bmad(tmp_path):
+ """The early return has to let a repo with a `_bmad/` through even when nothing
+ else is configured: a plugin-free, seed-free project on the renderer-era
+ primitive is exactly the cohort this fixes."""
+ wt, repo = tmp_path / "wt", tmp_path / "repo"
+ _write_bmad_surface(repo)
+
+ provision_worktree(wt, [], repo)
+
+ assert (wt / "_bmad" / "scripts" / "render_skill.py").is_file()
+
+
# ----------------------------------------------------------------- seed file modes (issue #126)
diff --git a/tests/test_model.py b/tests/test_model.py
index b6c6789f..4511da0b 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -160,6 +160,61 @@ def test_sentinel_kind_defaults_empty_for_legacy_state():
assert StoryTask.from_dict(doc).sentinel_kind == ""
+def test_pre_harvest_ledger_round_trips():
+ """Three text states, and the `""` one is load-bearing rather than decorative:
+ the natural deserializer (`str(d.get(k, "")) or None`) collapses a persisted
+ EMPTY snapshot — the ledger existed and was empty — into None, and None is the
+ engine's instruction to UNLINK. That spelling would delete a file the snapshot
+ says was there."""
+ for value in ("# Deferred Work\n\n### DW-1: something\n", "", None):
+ task = StoryTask(story_key="1-1-a", epic=1, pre_harvest_ledger=value)
+ restored = StoryTask.from_dict(task.to_dict()).pre_harvest_ledger
+ assert restored == value
+ assert (restored is None) is (value is None)
+
+
+def test_pre_harvest_ledger_captured_round_trips():
+ """The companion flag is what separates "no ledger existed" from "no snapshot was
+ taken"; a round-trip that lost the False would leave a disarmed task looking armed
+ with a None text, i.e. armed to unlink."""
+ for value in (True, False):
+ task = StoryTask(story_key="1-1-a", epic=1, pre_harvest_ledger_captured=value)
+ assert StoryTask.from_dict(task.to_dict()).pre_harvest_ledger_captured is value
+
+
+def test_pre_harvest_ledger_defaults_disarmed_for_legacy_state():
+ """Both keys absent ⇒ nothing armed, which is the hands-off default: a task
+ persisted before the fields existed makes the replay's restore a no-op rather
+ than an unlink of a ledger it knows nothing about. This is the plain
+ `bool(d.get(k, False))` idiom, and here — unlike the presence bit this pair
+ replaces — it is the correct one, because "missing" and "nothing captured" mean
+ the same thing."""
+ doc = StoryTask(story_key="1-1-a", epic=1).to_dict()
+ del doc["pre_harvest_ledger"] # state.json from before the fields existed
+ del doc["pre_harvest_ledger_captured"]
+ task = StoryTask.from_dict(doc)
+ assert task.pre_harvest_ledger is None
+ assert task.pre_harvest_ledger_captured is False
+
+
+def test_harvest_wrote_ledger_round_trips():
+ """Persistence is the whole point of this flag, not an incidental property of
+ living on the task: a crash replay re-runs the harvest, which dedupes against the
+ dead attempt's entries and reports filing nothing, so the only honest answer to
+ "did the orchestrator write this ledger?" is the one that came off disk."""
+ for value in (True, False):
+ task = StoryTask(story_key="1-1-a", epic=1, harvest_wrote_ledger=value)
+ assert StoryTask.from_dict(task.to_dict()).harvest_wrote_ledger is value
+
+
+def test_harvest_wrote_ledger_defaults_false_for_legacy_state():
+ """Absent ⇒ nothing of ours is in the diff, which is the conservative default: the
+ gate then counts the ledger as the session's work, the pre-#405 behaviour."""
+ doc = StoryTask(story_key="1-1-a", epic=1).to_dict()
+ del doc["harvest_wrote_ledger"] # state.json from before the field existed
+ assert StoryTask.from_dict(doc).harvest_wrote_ledger is False
+
+
def test_restore_patch_round_trips():
task = StoryTask(story_key="1-1-a", epic=1, restore_patch="artifacts/attempt.patch")
assert StoryTask.from_dict(task.to_dict()).restore_patch == "artifacts/attempt.patch"
diff --git a/tests/test_plugin_workflows.py b/tests/test_plugin_workflows.py
index e3d78fec..b9497f2b 100644
--- a/tests/test_plugin_workflows.py
+++ b/tests/test_plugin_workflows.py
@@ -440,3 +440,78 @@ def test_example_plugin_inert_until_enabled(project):
assert gr is not None and gr.instance is None and gr.trusted is False
# its workflow is declared but inert (the plugin is not active)
assert reg.workflows_for("post_dev_phase") == []
+
+
+def test_workflow_marker_path_follows_the_resolved_dev_primitive(project):
+ """#405: the completion-contract marker filename carries the dev primitive's
+ name as its prefix, and that name is disk-resolved like every other prompt
+ site — a post-rename project gets `bmad-build-auto-result-*`. The adapter
+ matches the prefix (devcontract.FALLBACK_RESULT_PREFIXES accepts both
+ spellings), so this is about the two halves agreeing, not about the name.
+
+ One adapter serves both roles here, so this cannot see WHICH role's tree was
+ resolved — that is the sibling below."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ captured: list = []
+ setup_story(project)
+ install_build_auto_skill(project.project, ".claude/skills")
+ reg = PluginRegistry([LoadedPlugin(manifest=wf_manifest("wf"))])
+ script = [
+ dev_effect(project, "1-1-a"),
+ workflow_effect(captured),
+ review_effect(project, "1-1-a", clean=True),
+ ]
+ engine, adapter = make_engine(project, script, reg)
+ attach_profile(adapter)
+
+ assert engine.run().done == 1
+ assert len(captured) == 1
+ assert "bmad-build-auto-result-1-1-a-wf.doc-1.md" in captured[0].prompt
+ assert "bmad-dev-auto-result-" not in captured[0].prompt
+
+
+def test_workflow_marker_path_follows_its_own_roles_tree(project):
+ """#405: a workflow declares its role and runs on THAT adapter, so its
+ completion marker must name the primitive resolved on that adapter's skill
+ tree — not the dev adapter's.
+
+ A run can mix trees at different upstream eras: dev=claude reads
+ `.claude/skills` (post-rename `bmad-build-auto`), review=codex reads
+ `.agents/skills` (a marker-complete legacy `bmad-dev-auto`). `wf_manifest`
+ declares `role = "review"`, so the contract this session is handed must
+ spell the legacy name; resolving off the dev tree would order a codex
+ session to write a file named after a skill only the claude tree carries.
+ Discovery would still read either back (FALLBACK_RESULT_PREFIXES matches
+ both spellings unconditionally) — the defect is the prompt contradicting
+ the session's own tree, which is the claim CHANGELOG's "a run mixing
+ `.claude/skills` and `.agents/skills` gets the right one per role" makes."""
+ from conftest import attach_profile, install_build_auto_skill, install_dev_base_skills
+
+ captured: list = []
+ setup_story(project)
+ install_build_auto_skill(project.project, ".claude/skills")
+ install_dev_base_skills(project.project, ".agents/skills", folder_id=False)
+ reg = PluginRegistry([LoadedPlugin(manifest=wf_manifest("wf"))])
+ review = attach_profile(
+ MockAdapter(
+ [workflow_effect(captured), review_effect(project, "1-1-a", clean=True)],
+ usage_per_session=TokenUsage(input_tokens=10, output_tokens=5),
+ ),
+ "codex",
+ )
+ engine, dev = make_engine(project, [dev_effect(project, "1-1-a")], reg, review_adapter=review)
+ attach_profile(dev, "claude")
+
+ assert engine.run().done == 1
+ assert len(captured) == 1
+ assert "bmad-dev-auto-result-1-1-a-wf.doc-1.md" in captured[0].prompt
+ assert "bmad-build-auto-result-" not in captured[0].prompt
+ # Fixture provenance, NOT a second witness: this assert stays GREEN when the
+ # marker path's `role` argument is ablated away (measured), because the
+ # `.agents/skills` cache entry is populated by the review PROMPT site, which
+ # this change did not touch. The two asserts above carry the whole signal.
+ assert engine._dev_skill_cache == {
+ ".claude/skills": "bmad-build-auto",
+ ".agents/skills": "bmad-dev-auto",
+ }
diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py
index 23ab58fc..0fb27f7f 100644
--- a/tests/test_stories_engine.py
+++ b/tests/test_stories_engine.py
@@ -63,11 +63,20 @@ def setup_stories(paths, entries: list[dict], *, spec_folder: str = SPEC_FOLDER)
def stories_dev_effect(
- *, final_status: str = "done", followup_review: bool = False, prose_status: str | None = None
+ *,
+ final_status: str = "done",
+ followup_review: bool = False,
+ prose_status: str | None = None,
+ deferred=None,
+ write_src: bool = True,
):
"""Simulate a bmad-dev-auto folder+id dispatch: read the story id + spec
folder from the session env (as the real adapter does), write the id-keyed
- story spec, and touch real code so proof-of-work passes."""
+ story spec, and touch real code so proof-of-work passes.
+
+ ``write_src=False`` mirrors `conftest.dev_effect`'s knob of the same name: it
+ keeps the session from touching code at all, the only way to express a stories
+ session whose whole post-baseline diff belongs to the ORCHESTRATOR."""
def effect(spec) -> SessionResult:
story_id = spec.env["BMAD_LOOP_STORY_KEY"]
@@ -77,8 +86,9 @@ def effect(spec) -> SessionResult:
stories_dir.mkdir(parents=True, exist_ok=True)
sp = stories_dir / f"{story_id}-slug.md"
src = Path(spec.cwd) / "src.txt"
- src.write_text(src.read_text() + f"work for {story_id}\n")
- write_spec(sp, final_status, baseline, prose_status=prose_status)
+ if write_src:
+ src.write_text(src.read_text() + f"work for {story_id}\n")
+ write_spec(sp, final_status, baseline, prose_status=prose_status, deferred=deferred)
return SessionResult(
status="completed",
result_json={
@@ -97,7 +107,7 @@ def effect(spec) -> SessionResult:
return effect
-def stories_checkpoint_effect():
+def stories_checkpoint_effect(*, deferred=None):
"""Simulate bmad-dev-auto honoring `Halt after planning.`: on a plan-halt leg
(BMAD_LOOP_PLAN_HALT set by the engine) write the id-keyed spec at
ready-for-dev with NO code change — the plan is just the spec — and mark the
@@ -120,14 +130,14 @@ def effect(spec) -> SessionResult:
"escalations": [],
}
if spec.env.get("BMAD_LOOP_PLAN_HALT"):
- write_spec(sp, "ready-for-dev", baseline)
+ write_spec(sp, "ready-for-dev", baseline, deferred=deferred)
return SessionResult(
status="completed",
result_json={**common, "status": "ready-for-dev", "plan_halt": True},
)
src = Path(spec.cwd) / "src.txt"
src.write_text(src.read_text() + f"work for {story_id}\n")
- write_spec(sp, "done", baseline)
+ write_spec(sp, "done", baseline, deferred=deferred)
return SessionResult(
status="completed",
result_json={**common, "status": "done", "followup_review_recommended": False},
@@ -1220,3 +1230,117 @@ def test_entry_for_unreadable_manifest_journals_warning_once(project):
# a second call for the same story does not re-journal (dedup per story key)
assert engine._entry_for(task) is None
assert len(_kinds(engine.journal, "stories-manifest-unreadable")) == 1
+
+
+def test_dev_prompt_spells_the_post_rename_primitive(project):
+ """#405: folder+id dispatch invokes the primitive resolved from the dev
+ adapter's skill tree — both the fresh leg and the inherited repair leg."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ setup_stories(project, [entry("1")])
+ install_build_auto_skill(project.project, ".claude/skills")
+ engine, adapter = make_engine(project, [])
+ attach_profile(adapter)
+ task = StoryTask(story_key="1", epic=0)
+
+ assert (
+ engine._dev_prompt(task, None)
+ == "/bmad-build-auto Spec folder: _bmad-output/epic-1. Story id: 1."
+ )
+ feedback = project.implementation_artifacts / "feedback.md"
+ assert engine._dev_prompt(task, feedback).startswith(
+ "/bmad-build-auto Resume the autonomous dev session"
+ )
+
+
+# ---------------- frontmatter `deferred:` harvest parity (BMAD-METHOD #2640)
+
+
+STORY_FINDING = {
+ "summary": "The id-keyed spec loader rescans on every call",
+ "evidence": "resolve_story_spec globs the folder each time",
+ "location": "src/bmad_loop/stories.py:120",
+ "severity": "low",
+}
+
+
+def test_stories_mode_harvests_spec_deferrals_into_the_ledger(project):
+ """Stories mode overrides `_post_dev_state_sync` to a no-op (no sprint board,
+ no bundle ledger to flip) — but the harvest is its own call in the shared dev
+ phase, so a folder+id story's deferred findings reach the ledger exactly like
+ a sprint story's. Without that, stories-mode runs would starve the sweep."""
+ from bmad_loop import deferredwork
+
+ setup_stories(project, [entry("1")])
+ engine, _ = make_engine(project, [stories_dev_effect(deferred=[STORY_FINDING])])
+ summary = engine.run()
+
+ assert summary.done == 1 and not summary.paused
+ entries = deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8"))
+ assert len(entries) == 1
+ assert entries[0].title == "The id-keyed spec loader rescans on every call"
+ assert entries[0].open
+ assert "source_spec: `1-slug.md`" in entries[0].body
+ assert "location: src/bmad_loop/stories.py:120" in entries[0].body
+ harvested = _kinds(engine.journal, "spec-deferrals-harvested")
+ assert len(harvested) == 1 and harvested[0]["dw_ids"] == ["DW-1"]
+
+
+def test_plan_halt_leg_does_not_harvest_then_implement_leg_does(project):
+ """A plan-halt leg leaves the spec at `ready-for-dev` — a successful terminal
+ for that leg, but NOT the success status the harvest gates on. Its findings
+ stay in frontmatter until the implement leg finalizes the story, so a plan a
+ human might still reject cannot seed the ledger."""
+ from bmad_loop import deferredwork
+
+ setup_stories(project, [entry("1", spec_checkpoint=True)])
+ engine, _ = make_engine(project, [stories_checkpoint_effect(deferred=[STORY_FINDING])])
+ summary = engine.run()
+
+ assert summary.paused and summary.done == 0
+ assert status_of(read_frontmatter(story_spec(project, "1"))) == "ready-for-dev"
+ assert not project.deferred_work.exists()
+ assert not _kinds(engine.journal, "spec-deferrals-harvested")
+
+ resumed, _ = resume_engine(
+ project, engine, [stories_checkpoint_effect(deferred=[STORY_FINDING])]
+ )
+ rsummary = resumed.run()
+
+ assert rsummary.done == 1
+ entries = deferredwork.parse_ledger(project.deferred_work.read_text(encoding="utf-8"))
+ assert len(entries) == 1 and entries[0].open
+ assert _kinds(resumed.journal, "spec-deferrals-harvested")[0]["dw_ids"] == ["DW-1"]
+
+
+def test_stories_harvest_alone_is_not_proof_of_work(project):
+ """T2j, the stories leg of the harvest-vs-proof-of-work hazard, and the third
+ `_verify_dev_artifacts` override that has to pass `engine_written` through.
+
+ Stories mode already excludes `stories/` and `stories.yaml` from proof-of-work
+ (`verify._stories_relpaths`), but the ledger lives under implementation-artifacts
+ — a different subtree — so the harvest's own write lands squarely in the diff the
+ gate reads. Attempt 1 finalizes its spec, changes no code and records one
+ `deferred:` finding, so its entire post-baseline diff is the line the ORCHESTRATOR
+ wrote: it must RETRY. Attempt 2 does real work and proceeds.
+
+ `entry("1")` and not `spec_checkpoint=True` is load-bearing: a plan-halt leg
+ passes `extra_exclude=None`, which skips the proof-of-work gate outright, and the
+ test would pass on any build."""
+ setup_stories(project, [entry("1")])
+ engine, _ = make_engine(
+ project,
+ [
+ stories_dev_effect(write_src=False, deferred=[STORY_FINDING]),
+ stories_dev_effect(),
+ ],
+ )
+ summary = engine.run()
+
+ decisions = _kinds(engine.journal, "dev-decision")
+ assert [d["action"] for d in decisions] == ["retry", "proceed"]
+ assert "no changes" in decisions[0]["reason"]
+ # the harvest really did fire on the refused attempt — the exclusion is what the
+ # gate answered on, not an absent ledger
+ assert _kinds(engine.journal, "spec-deferrals-harvested")[0]["dw_ids"] == ["DW-1"]
+ assert summary.done == 1
diff --git a/tests/test_sweep.py b/tests/test_sweep.py
index 35519485..28acb9af 100644
--- a/tests/test_sweep.py
+++ b/tests/test_sweep.py
@@ -12,6 +12,7 @@
fault_read_text,
git,
migrate_effect,
+ passes_once,
triage_effect,
write_ledger,
write_legacy_ledger,
@@ -34,6 +35,7 @@
ScmPolicy,
StageAdapterPolicy,
SweepPolicy,
+ VerifyPolicy,
)
from bmad_loop.sweep import DecisionPrompter, SweepEngine, validate_migration, validate_triage
from bmad_loop.verify import worktree_clean
@@ -436,8 +438,9 @@ def test_sweep_happy_path(project):
def test_generic_skill_bundle_orchestrator_closes_ledger(project):
"""B4: on the generic bmad-dev-auto path the bundle session never edits the
- ledger; the orchestrator marks each owned dw id done (in _post_dev_state_sync)
- and verify_review_bundle confirms its own write. The invocation is freeform."""
+ ledger; the orchestrator marks each owned dw id done (in
+ _post_dev_accepted_sync) and verify_review_bundle confirms its own write. The
+ invocation is freeform."""
write_ledger(project, {"DW-1": "open", "DW-2": "open"})
plan = triage_result(
["DW-1", "DW-2"],
@@ -475,6 +478,269 @@ def test_generic_skill_bundle_orchestrator_closes_ledger(project):
assert "sweep-bundle-closed" in kinds
+def test_bundle_ledger_close_withheld_on_a_non_fixable_retry(project):
+ """The RETRY leg of the accepted-only close. The orchestrator closes a bundle's
+ dw ids in `_post_dev_accepted_sync`, below the artifact gate that can still send
+ the attempt back — so a close can never outlive the code it claims to resolve.
+
+ A close is the most expensive engine-side ledger write to leave behind:
+ `deferredwork.open_ids` only ever re-bundles `open` entries and there is no
+ reopen primitive, so a `done` id with no code behind it is invisible to every
+ future sweep, where a stray harvest entry merely reads as noise.
+
+ The ledger here is TRACKED, so `reset --hard` would revert a close by itself —
+ which is what makes this leg the WEAK one and the DEFER leg
+ (`..._when_a_completed_attempt_defers`) the load-bearing one. Kept because it
+ pins the mid-run state no other test sees: attempt 2 opens on a ledger that was
+ never rewritten, rather than on one rewritten and then put back."""
+ write_ledger(project, {"DW-1": "open"}) # committed ⇒ tracked
+ plan = triage_result(
+ ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}]
+ )
+ seen = []
+
+ def capture_then_die(spec):
+ seen.append(project.deferred_work.read_text(encoding="utf-8"))
+ return SessionResult(status="died") # budget spent ⇒ DEFER, the run ends
+
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ limits=LimitsPolicy(max_dev_attempts=2),
+ )
+ engine, _ = make_sweep(
+ project,
+ [
+ triage_effect(plan),
+ # reports dw_ids the task does not own, so the artifact gate fails on the
+ # CLAIM — after a spec status (`done`) that would otherwise authorize the
+ # close. The attempt is discarded; the ledger must never have been touched.
+ bundle_dev_effect(project, "fix", ["DW-99"], mark_ledger=False, followup_review=False),
+ capture_then_die,
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ # the scenario really is: spec finalized, gate failed, rolled back
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert decisions[0]["action"] == "retry" and "do not match" in decisions[0]["reason"]
+ assert "rollback-auto" in journal_text(engine)
+ assert "sweep-bundle-closed" not in {e["kind"] for e in engine.journal.entries()}
+
+ # attempt 2 opens on a ledger the discarded attempt never rewrote …
+ assert "status: open" in seen[0]
+ assert "resolved by sweep bundle dw-fix" not in seen[0]
+ # … and that is where the run leaves it, so a later sweep can re-bundle DW-1
+ assert summary.deferred == 1 and not summary.paused
+ assert ledger_entries(project)["DW-1"].open
+
+
+def test_bundle_ledger_close_withheld_when_a_completed_attempt_defers(project):
+ """The DEFER terminus — the leg that made the close's position a CORRECTNESS
+ question rather than a tidiness one, and the leg no ledger snapshot in
+ `_dev_phase` could ever have covered.
+
+ Differs from `..._on_a_non_fixable_retry` above by exactly one thing: attempt 2
+ is a second COMPLETED session rather than a corpse. That is enough. A completed
+ attempt runs the engine-side ledger writes; the gate then fails on the dw_ids
+ claim with the budget spent, so `decide_dev` returns DEFER — and `_defer` takes
+ its own snapshot of the ledger AFTER those writes, resets, and writes the
+ snapshot back on purpose, so harvested findings survive a defer. A close written
+ above the gate rode that write-back over the top of the rollback that had just
+ correctly reverted it: `status: done` on an id whose code no longer exists, and
+ `open_ids` never re-bundles it, so the work is silently LOST. Closing only for an
+ accepted attempt means there is nothing for `_defer` to resurrect.
+
+ The `dev-decision` assert is load-bearing: with `max_dev_attempts = 1` the
+ budget is already spent at attempt 1 and the run DEFERS without ever reaching a
+ RETRY, so the ledger is never rewritten and the test would pass vacuously."""
+ write_ledger(project, {"DW-1": "open"}) # committed ⇒ tracked
+ plan = triage_result(
+ ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}]
+ )
+
+ def mismatching_attempt():
+ return bundle_dev_effect(
+ project, "fix", ["DW-99"], mark_ledger=False, followup_review=False
+ )
+
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ limits=LimitsPolicy(max_dev_attempts=2),
+ )
+ engine, _ = make_sweep(
+ project,
+ [triage_effect(plan), mismatching_attempt(), mismatching_attempt()],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ actions = [e["action"] for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert actions == ["retry", "defer"]
+ assert summary.deferred == 1 and not summary.paused
+ text = project.deferred_work.read_text(encoding="utf-8")
+ assert "resolved by sweep bundle dw-fix" not in text
+ assert deferredwork.open_ids(text) == {"DW-1"}
+ assert "change for dw-fix" not in (project.project / "src.txt").read_text()
+ kinds = {e["kind"] for e in engine.journal.entries()}
+ assert "sweep-bundle-closed" not in kinds
+ # ...and the defer's own reopen stayed silent rather than journalling an empty
+ # list: withholding the close is what fixes this leg, not undoing one
+ assert "sweep-bundle-reopened" not in kinds
+
+
+def test_bundle_ledger_close_withheld_when_a_critical_escalation_preempts_the_gate(project):
+ """The close is gated on the DECISION being PROCEED, not on `outcome.ok`, and
+ this is the one scenario that can tell the two apart.
+
+ `escalation.decide_dev` checks `critical_escalations(result_json)` before it
+ looks at the outcome at all, so a session that finalized its spec, produced a
+ matching `dw_ids` claim and passed every artifact gate still PAUSEs when it
+ raises a CRITICAL. Gating on `outcome.ok` would close the ids on the way to a
+ human escalation — recording as resolved a bundle whose whole point is that the
+ orchestrator could not resolve it.
+
+ Distinct from `test_escalated_bundle_resume_skips_it_and_runs_rest`, which uses
+ `bundle_dev_escalates`: that session writes its spec `blocked`, so
+ `_close_bundle_ledger_when_spec_status`'s own status guard already refuses and
+ the escalation proves nothing about the decision gate."""
+ write_ledger(project, {"DW-1": "open"})
+ plan = triage_result(
+ ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}]
+ )
+ clean = bundle_dev_effect(project, "fix", ["DW-1"], mark_ledger=False, followup_review=False)
+
+ def clean_but_critical(spec):
+ result = clean(spec)
+ result.result_json["escalations"] = [
+ {"type": "bundle-item-blocked", "severity": "CRITICAL", "detail": "intent gap"}
+ ]
+ return result
+
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ )
+ engine, _ = make_sweep(project, [triage_effect(plan), clean_but_critical], policy=pol)
+ summary = engine.run()
+
+ # the gate really did pass — the PAUSE comes from the escalation, not a failure
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert len(decisions) == 1
+ assert decisions[0]["action"] == "pause" and "CRITICAL" in decisions[0]["reason"]
+ assert summary.paused
+ assert ledger_entries(project)["DW-1"].open
+ assert "sweep-bundle-closed" not in {e["kind"] for e in engine.journal.entries()}
+
+
+def test_bundle_ledger_close_reopened_when_the_review_leg_defers(project):
+ """The REVIEW-leg counterpart, which withholding the close cannot reach.
+
+ By review time the close is not premature — the dev attempt WAS accepted, and
+ `verify_review_bundle` requires the entries `done`, so they have to be on disk.
+ Only the later review failure makes them wrong, and then `_defer` writes its own
+ post-close snapshot back over the `reset --hard` that had correctly reverted
+ them. Deliberately, because a harvested finding's ledger entry is its only
+ surviving record once `_stash_deferred_artifacts` moves the spec away — but the
+ restore replays the whole file, so the closes ride it too and name code that no
+ longer exists. `_reopen_ledger_after_defer` takes back exactly this run's own.
+
+ Route: the final review session dies with the cycle budget spent, so
+ `decide_review_session` returns DEFER."""
+ write_ledger(project, {"DW-1": "open"}) # committed ⇒ tracked
+ plan = triage_result(
+ ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}]
+ )
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=True, trigger="always"),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ limits=LimitsPolicy(max_review_cycles=1),
+ )
+ engine, _ = make_sweep(
+ project,
+ [
+ triage_effect(plan),
+ bundle_dev_effect(project, "fix", ["DW-1"], mark_ledger=False),
+ lambda spec: SessionResult(status="died"),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ # the scenario really is: closed at dev, then discarded by a review-leg defer
+ assert "sweep-bundle-closed" in kinds
+ assert summary.deferred == 1 and not summary.paused
+ assert "change for dw-fix" not in (project.project / "src.txt").read_text()
+
+ text = project.deferred_work.read_text(encoding="utf-8")
+ assert deferredwork.open_ids(text) == {"DW-1"}
+ assert "resolved by sweep bundle dw-fix" not in text
+ reopened = [e for e in engine.journal.entries() if e["kind"] == "sweep-bundle-reopened"]
+ assert len(reopened) == 1 and reopened[0]["dw_ids"] == ["DW-1"]
+ # the tree is clean: the reopen restored the ledger byte-for-byte, so the defer
+ # leaves nothing staged for the next cycle's baseline
+ assert worktree_clean(project.project)
+
+
+def test_bundle_ledger_close_reopened_when_review_disabled_defers_at_the_gate(project, tmp_path):
+ """The second route into the same `_defer`: with `review.enabled = false` there is
+ no review session to decide anything, and `_skip_review_and_commit` defers
+ directly when its own verify fails.
+
+ `passes_once` is GREEN at the dev gate — letting the attempt be accepted and the
+ ids closed — and RED at the review gate. Nothing else can produce that ordering:
+ every other input to `verify_review_bundle` is already satisfied by the accepted
+ dev attempt. It latches in the run dir and is written for cmd as well as sh,
+ because the Windows CI lane runs this test too and mis-PARSES a POSIX command
+ rather than failing on it."""
+ write_ledger(project, {"DW-1": "open"})
+ plan = triage_result(
+ ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}]
+ )
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ verify=VerifyPolicy(commands=(passes_once(tmp_path / "verify-ran"),)),
+ # no dev budget left for `_skip_review_and_commit`'s repair phase, so the
+ # fixable verify failure goes straight to the defer this test is about
+ limits=LimitsPolicy(max_dev_attempts=1),
+ )
+ engine, _ = make_sweep(
+ project,
+ [
+ triage_effect(plan),
+ bundle_dev_effect(project, "fix", ["DW-1"], mark_ledger=False, followup_review=False),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ kinds = [e["kind"] for e in engine.journal.entries()]
+ assert "sweep-bundle-closed" in kinds
+ assert summary.deferred == 1 and not summary.paused
+ assert "change for dw-fix" not in (project.project / "src.txt").read_text()
+ assert deferredwork.open_ids(project.deferred_work.read_text(encoding="utf-8")) == {"DW-1"}
+ assert "sweep-bundle-reopened" in kinds
+
+
def test_bundle_ledger_close_skips_on_unreadable_spec(project, monkeypatch):
"""The bundle counterpart of the sprint-board sync: an unreadable bundle spec
must not close any dw id (the ledger write is a repair — it must never fire off
@@ -494,7 +760,7 @@ def test_bundle_ledger_close_skips_on_unreadable_spec(project, monkeypatch):
task = StoryTask(story_key="dw-fix-things", epic=0, dw_ids=["DW-1", "DW-2"])
fault_read_text(monkeypatch, sp)
- engine._post_dev_state_sync(task, {"spec_file": str(sp)})
+ engine._post_dev_accepted_sync(task, {"spec_file": str(sp)})
entries = ledger_entries(project)
assert entries["DW-1"].status == "open" and entries["DW-2"].status == "open"
@@ -2303,3 +2569,155 @@ def test_graceful_stop_between_cycles_skips_next_triage(project):
entries = ledger_entries(project)
assert entries["DW-1"].status.startswith("done") and entries["DW-2"].open
assert stops[-1]["remaining"] == 1 # DW-2, generated in cycle 1, still open
+
+
+def test_generic_bundle_prompt_spells_the_post_rename_primitive(project):
+ """#405: all three bundle legs (restore, fresh implement, repair) invoke the
+ primitive resolved from the dev adapter's skill tree, not a hardcoded name."""
+ from conftest import attach_profile, install_build_auto_skill
+
+ install_build_auto_skill(project.project, ".claude/skills")
+ engine, adapter = make_sweep(project, [])
+ attach_profile(adapter)
+ spec = str(project.implementation_artifacts / "spec-dw-fix.md")
+ task = StoryTask(
+ story_key="dw-fix",
+ epic=0,
+ dw_ids=["DW-1"],
+ bundle_file="/run/bundles/fix/intent.md",
+ spec_file=spec,
+ restore_patch="/run/artifacts/attempt-dw-fix.patch",
+ )
+
+ assert engine._generic_bundle_prompt(task, None).startswith(
+ "/bmad-build-auto Resume review of the in-review spec"
+ )
+ task.restore_patch = None
+ assert engine._generic_bundle_prompt(task, None).startswith(
+ "/bmad-build-auto Implement the deferred-work bundle"
+ )
+ feedback = project.implementation_artifacts / "feedback.md"
+ assert engine._generic_bundle_prompt(task, feedback).startswith(
+ "/bmad-build-auto Resume the autonomous dev session"
+ )
+
+
+def test_generic_bundle_harvests_spec_deferrals_alongside_its_closures(project):
+ """A bundle session can defer NEW findings while resolving the ones it owns
+ (BMAD-METHOD #2640 puts both in the bundle spec's frontmatter). Both ledger
+ writes must land: the bundle's own dw ids close, the new finding opens as a
+ fresh entry — and `verify_review_bundle` stays green, because it gates on the
+ bundle's own ids, not on the ledger having no open entries at all."""
+ write_ledger(project, {"DW-1": "open", "DW-2": "open"})
+ plan = triage_result(
+ ["DW-1", "DW-2"],
+ bundles=[{"name": "fix-things", "dw_ids": ["DW-1", "DW-2"], "intent": "fix both"}],
+ )
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ scm=ScmPolicy(rollback_on_failure=True),
+ )
+ engine, _ = make_sweep(
+ project,
+ [
+ triage_effect(plan),
+ bundle_dev_effect(
+ project,
+ "fix-things",
+ ["DW-1", "DW-2"],
+ mark_ledger=False,
+ deferred=[
+ {
+ "summary": "The retry cap should be configurable",
+ "evidence": "hardcoded while fixing DW-1",
+ "location": "src/retry.py:88",
+ "severity": "low",
+ }
+ ],
+ ),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ assert not summary.paused
+ assert engine.state.tasks["dw-fix-things"].phase == Phase.DONE
+ entries = ledger_entries(project)
+ assert entries["DW-1"].status.startswith("done")
+ assert entries["DW-2"].status.startswith("done")
+ assert entries["DW-3"].open
+ assert entries["DW-3"].title == "The retry cap should be configurable"
+ assert "origin: spec-deferred " in entries["DW-3"].body
+ assert "source_spec: `spec-dw-fix-things.md`" in entries["DW-3"].body
+ assert "location: src/retry.py:88" in entries["DW-3"].body
+ # the task's bundle binding is untouched — verify_dev_bundle/verify_review_bundle
+ # compare dw_ids for equality, so a harvested id leaking in would fail both gates
+ assert engine.state.tasks["dw-fix-things"].dw_ids == ["DW-1", "DW-2"]
+ harvested = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvested) == 1 and harvested[0]["dw_ids"] == ["DW-3"]
+ assert worktree_clean(project.project)
+
+
+def test_bundle_harvest_alone_is_not_proof_of_work(project):
+ """T2d. The bundle leg of the same hazard, and the costlier one: a close marks real
+ dw ids `done`, `deferredwork.open_ids` re-bundles only `open` entries, so a bundle
+ accepted on the orchestrator's own ledger write takes its ids out of every future
+ sweep with no code behind them.
+
+ The session finalizes its spec, changes no code and records one new finding. The
+ only post-baseline diff is the line the harvest wrote above the gate, so the attempt
+ must be refused — DW-1 stays `open` and no close is journalled. `max_dev_attempts=1`
+ turns the refusal straight into the terminal deferral rather than a retry."""
+ write_ledger(project, {"DW-1": "open"})
+ plan = triage_result(
+ ["DW-1"],
+ bundles=[{"name": "fix-things", "dw_ids": ["DW-1"], "intent": "fix it"}],
+ )
+ pol = Policy(
+ gates=GatesPolicy(mode="none"),
+ notify=QUIET,
+ review=ReviewPolicy(enabled=False),
+ dev=DevPolicy(skill="bmad-dev-auto"),
+ limits=LimitsPolicy(max_dev_attempts=1),
+ scm=ScmPolicy(rollback_on_failure=True),
+ )
+ engine, _ = make_sweep(
+ project,
+ [
+ triage_effect(plan),
+ bundle_dev_effect(
+ project,
+ "fix-things",
+ ["DW-1"],
+ mark_ledger=False,
+ write_src=False,
+ deferred=[
+ {
+ "summary": "The retry cap should be configurable",
+ "evidence": "hardcoded while fixing DW-1",
+ "location": "src/retry.py:88",
+ "severity": "low",
+ }
+ ],
+ ),
+ ],
+ policy=pol,
+ )
+ summary = engine.run()
+
+ decisions = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"]
+ assert [d["action"] for d in decisions] == ["defer"]
+ assert "no changes" in decisions[0]["reason"]
+ # done == 1 is the `sweep-triage` task, which always lands; the BUNDLE deferred
+ assert summary.deferred == 1
+ assert engine.state.tasks["dw-fix-things"].phase == Phase.DEFERRED
+ # the harvest really did fire — the gate answered on the exclusion, not on an
+ # untouched ledger …
+ harvested = [e for e in engine.journal.entries() if e["kind"] == "spec-deferrals-harvested"]
+ assert len(harvested) == 1 and harvested[0]["dw_ids"] == ["DW-2"]
+ # … and the bundle's own id was never closed, so a later sweep can still re-bundle it
+ assert ledger_entries(project)["DW-1"].open
+ assert "sweep-bundle-closed" not in {e["kind"] for e in engine.journal.entries()}
diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py
index cb2769fe..386660a4 100644
--- a/tests/test_tui_app.py
+++ b/tests/test_tui_app.py
@@ -14,7 +14,7 @@
from pathlib import Path
import pytest
-from conftest import install_bmad_config, make_validate_document, write_sprint
+from conftest import git, install_bmad_config, make_validate_document, write_sprint
from rich.console import Console
from rich.text import Text
from textual.events import MouseMove
@@ -32,7 +32,7 @@
TabbedContent,
)
-from bmad_loop import documents
+from bmad_loop import bmadconfig, documents
from bmad_loop import policy as policy_mod
from bmad_loop.adapters.multiplexer import MultiplexerError
from bmad_loop.journal import Journal, save_state
@@ -1335,6 +1335,78 @@ async def test_dirty_worktree_blocks_launch(project, monkeypatch):
assert not calls
+def _split_root_tui_project(project):
+ """The #414 pair, written where the guard reads them. Deliberately left
+ UNCOMMITTED: the guard is ordered ahead of the clean-tree gate exactly as
+ `cmd_run` orders it, and a committed fixture could not tell the two orders
+ apart."""
+ install_bmad_config(project)
+ cfg = project.project / "_bmad" / "bmm" / "config.yaml"
+ cfg.write_text(cfg.read_text() + "repo_root: '{project-root}/git-root'\n", encoding="utf-8")
+ (project.project / ".bmad-loop").mkdir(parents=True, exist_ok=True)
+ (project.project / ".bmad-loop" / "policy.toml").write_text(
+ '[adapter]\nname = "claude"\n\n[scm]\nisolation = "worktree"\n', encoding="utf-8"
+ )
+
+
+async def test_worktree_isolation_under_a_repo_root_override_blocks_launch(project, monkeypatch):
+ """#414: the TUI launches a detached CLI, and that CLI refuses this combination
+ itself — this guard exists so the operator gets a toast instead of a pane that
+ dies immediately. Asserted against the sole producer of the text rather than a
+ literal, so a reworded message cannot drift this test away from the CLI's."""
+ calls = []
+ monkeypatch.setattr(launch, "mux_available", lambda: True)
+ monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a))
+ _split_root_tui_project(project)
+ expected = bmadconfig.worktree_isolation_conflict(
+ bmadconfig.load_paths(project.project), "worktree"
+ )
+ assert expected is not None, "the fixture really does carry the conflicting pair"
+
+ app = BmadLoopApp(project.project)
+ async with app.run_test() as pilot:
+ await until(pilot, lambda: isinstance(app.screen, DashboardScreen))
+ await pilot.press("r")
+ await until(pilot, lambda: isinstance(app.screen, StartRunModal))
+ await pilot.click(await ready(pilot, "#ok"))
+ await until(pilot, lambda: expected in notifications(app))
+ # The tree is dirty, so this also pins the ORDER: the clean-tree gate would
+ # otherwise have spoken first and sent the operator to commit something
+ # that is not the problem.
+ assert not any("not clean" in m for m in notifications(app))
+ assert not calls
+
+
+async def test_unreadable_policy_falls_through_the_isolation_guard(project, monkeypatch):
+ """The guard's deliberate blind spot, and the one branch where a wrong `except`
+ tuple silently disables it. It cannot tell "no conflict" from "could not look",
+ so it defers to the detached CLI, which reads the same two files and fails
+ loudly on whichever it cannot parse. The bytes here are undecodable rather than
+ merely malformed: `read_text` raises `UnicodeDecodeError`, which is a ValueError
+ and NOT an OSError, so it escapes the obvious tuple and would take the TUI down
+ instead of launching.
+
+ Committed, unlike the sibling above: this one asserts the launch actually
+ HAPPENS, so the clean-tree gate downstream has to be satisfied or it would
+ block for an unrelated reason and the fall-through would go unwitnessed."""
+ calls = []
+ monkeypatch.setattr(launch, "mux_available", lambda: True)
+ monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a))
+ _split_root_tui_project(project)
+ git(project.project, "add", "-A")
+ git(project.project, "commit", "-q", "-m", "split roots")
+ (project.project / ".bmad-loop" / "policy.toml").write_bytes(b'[scm]\nisolation = "\xff\xfe"\n')
+
+ app = BmadLoopApp(project.project)
+ async with app.run_test() as pilot:
+ await until(pilot, lambda: isinstance(app.screen, DashboardScreen))
+ await pilot.press("r")
+ await until(pilot, lambda: isinstance(app.screen, StartRunModal))
+ await pilot.click(await ready(pilot, "#ok"))
+ await until(pilot, lambda: calls)
+ assert not any("isolation" in m for m in notifications(app))
+
+
async def test_live_run_asks_for_confirmation(project, monkeypatch):
calls = []
monkeypatch.setattr(launch, "mux_available", lambda: True)
diff --git a/tests/test_verify.py b/tests/test_verify.py
index 0919c958..fc2ab878 100644
--- a/tests/test_verify.py
+++ b/tests/test_verify.py
@@ -913,6 +913,37 @@ def test_verify_review_bundle_ledger_oserror_degrades_to_retry(project, monkeypa
assert "DW-1" not in out.reason # not the "entries not marked done" verdict
+def test_path_tracked_separates_tracked_untracked_and_ignored(project):
+ """The three states a rollback has to tell apart, plus the one that reads wrong
+ if you probe the filesystem instead of the index.
+
+ `untracked_files` alone cannot do this: it answers False for an ignored path AND
+ for a tracked one, so a caller reasoning over it in isolation files every ignored
+ path under whichever branch it wrote last — which is exactly how a gitignored
+ deferred-work ledger came to be treated as "already reverted by the reset" (#405).
+ """
+ repo = project.project
+ (repo / ".gitignore").write_text(".bmad-loop/runs/\nblind/\n", encoding="utf-8")
+ (repo / "blind").mkdir()
+ (repo / "blind" / "note.md").write_text("ignored\n", encoding="utf-8")
+ (repo / "loose.txt").write_text("untracked\n", encoding="utf-8")
+ git(repo, "add", ".gitignore")
+ git(repo, "commit", "-q", "-m", "ignore rules")
+
+ assert verify.path_tracked(repo, "src.txt") # committed by the fixture
+ assert not verify.path_tracked(repo, "loose.txt")
+ assert not verify.path_tracked(repo, "blind/note.md")
+ assert not verify.path_tracked(repo, "never/existed.md")
+ # ignored is invisible to the untracked probe too — neither signal alone is enough
+ assert "blind/note.md" not in verify.untracked_files(repo)
+ assert "loose.txt" in verify.untracked_files(repo)
+
+ # the state a plain `Path.exists()` gets backwards: the index entry outlives the
+ # file, and `reset --hard` is what puts it back — so it is still git's to restore.
+ (repo / "src.txt").unlink()
+ assert verify.path_tracked(repo, "src.txt")
+
+
def test_safe_rollback_reverts_tracked_and_removes_run_created(project):
repo = project.project
baseline = verify.rev_parse_head(repo)
diff --git a/uv.lock b/uv.lock
index 1b03f5c3..38a88279 100644
--- a/uv.lock
+++ b/uv.lock
@@ -17,7 +17,7 @@ wheels = [
[[package]]
name = "bmad-loop"
-version = "0.9.0"
+version = "0.9.1"
source = { editable = "." }
dependencies = [
{ name = "psutil", marker = "sys_platform == 'win32'" },