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 The settings editor with the [scm] section expanded: isolation, branch_per, merge_strategy, the seed-adapter-configs switch, and the extra-worktree-seed-files field.

-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-<hex>` — 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 +# `<prefix> <fingerprint>` — matched verbatim on every later harvest, so it is +# the dedup key and must never be reworded; `<prefix>-malformed <fingerprint>` +# 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 `<dev primitive>-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 +# `<project-root>/_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: <error>` 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 `<skill>/workflow.md` and refuses to start without it (`render entry is +# missing`), then rewrites every `[[bmad-snapshot:<rel>]]` 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 + ``<resolved-primitive>/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 + `<worktree-gitdir>/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: <error>`` 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/<anything + else>/`` 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 ``<tree>/<skill>`` when the worktree + lacks ``SKILL.md``, because then everything under the dir is missing and three rels + for one cause is noise; the fine ``<tree>/<skill>/<file>`` 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: <rel>. Story id: <id>.`` + ``/<dev primitive> Spec folder: <rel>. Story id: <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 ``<spec-folder>/stories/<id>-<slug>.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: <error>` 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 "<done spec from dev>" 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" + # `<skill>-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-<hex>` 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: <error>` 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 `<skill>/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/<skill>.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'" },