From 6b3ccdaa73fa6ba49b28cbb450df568b7fb4f74d Mon Sep 17 00:00:00 2001 From: t Date: Thu, 30 Jul 2026 15:28:11 -0700 Subject: [PATCH 01/41] fix(install): resolve bmad-build-auto rename, refuse the shim (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BMAD-METHOD #2651 (bmad-method >= 6.10.1-next.33) renamed the dev primitive bmad-dev-auto -> bmad-build-auto and left a forwarding shim under the old name. On an upgraded target project the shim failed `skills.base-incomplete` (it has no step files and no customize.toml), which hard-blocked validate/run/sweep/ resume, and worktree isolation never copied the new skill. Resolve the primitive on disk per skill tree instead of hardcoding a name: prefer bmad-build-auto, fall back to a marker-complete bmad-dev-auto, never accept the shim — its migration gate is interactive and would HALT an unattended session with nothing written to disk. - install: resolve_dev_primitive / dev_primitive_or_default / dev_primitive_warnings; missing_base_skills and missing_stories_support work off the resolved skill; BASE_SKILLS carries both eras so isolation copies whichever is installed - checks: skills.base-shim (problem), skills.dev-renderer + skills.customize-legacy (warnings — a renderer stub without _bmad/scripts/render_skill.py, and an orphaned _bmad/custom/bmad-dev-auto*.toml) - cli: validate names the resolved primitive and reports the new warnings - devcontract: match the no-spec fallback artifact under both result prefixes - policy: document that [dev] skill is the adapter discriminator, not the invoked name — a target project must not edit policy.toml to survive a rename --- src/bmad_loop/checks.py | 3 + src/bmad_loop/cli.py | 44 ++++- src/bmad_loop/devcontract.py | 17 +- src/bmad_loop/install.py | 286 +++++++++++++++++++++++---- src/bmad_loop/policy.py | 10 + tests/conftest.py | 79 +++++++- tests/test_cli.py | 116 ++++++++++- tests/test_devcontract.py | 9 +- tests/test_install.py | 367 +++++++++++++++++++++++++++++++++-- 9 files changed, 845 insertions(+), 86 deletions(-) diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 739841bb..da1c3591 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -59,6 +59,9 @@ "skills.base", "skills.base-missing", "skills.base-incomplete", + "skills.base-shim", + "skills.dev-renderer", + "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..d73df9a4 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -492,14 +492,29 @@ 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]) + base_trees = [p.skill_tree for p in profiles] + base_problems = install.missing_base_skills(project, base_trees) if profiles 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(base_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(base_trees)), + "dev_primitive": resolved, + }, ) report.extend(base_problems) + report.extend(install.dev_primitive_warnings(project, base_trees)) if getattr(args, "json", False): # getattr, not args.json: cmd_validate is called directly by tests (and by @@ -629,16 +644,19 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: 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). + """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 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 + ``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.""" from .adapters.profile import ProfileError, get_profile @@ -734,10 +752,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) diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 25e9e011..b2149427 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -80,9 +80,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) @@ -287,8 +291,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 +313,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/install.py b/src/bmad_loop/install.py index 24375f18..11db6f8b 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -53,8 +53,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 +72,56 @@ "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. +DEV_PRIMITIVE_NEW = "bmad-build-auto" +DEV_PRIMITIVE_LEGACY = "bmad-dev-auto" +DEV_PRIMITIVE_MARKERS = ("step-04-review.md", "customize.toml") + +# 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 validate warns — +# advisory, not a FAIL: only the script's presence is probed, never uv on PATH. +RENDERER_SCRIPT_REL = "_bmad/scripts/render_skill.py" +RENDERER_SCRIPT_MARKER = "render_skill.py" + +# 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 = "_bmad/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 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, ...)}`. +# 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,24 +129,76 @@ # 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. + """ + if (project / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file(): + return DEV_PRIMITIVE_NEW + legacy = project / tree / DEV_PRIMITIVE_LEGACY + if (legacy / "SKILL.md").is_file() and all( + (legacy / marker).is_file() 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 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 + Sprint mode drives any dev primitive; 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 + tree, confirm ``/step-01-clarify-and-route.md`` exists and carries the dispatch-protocol marker. Returns one problem :class:`Finding` per tree lacking it (empty = OK). Callers gate this on stories mode only — sprint-mode runs must not require the newer skill. @@ -124,8 +208,9 @@ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding 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 +222,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 +233,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 +245,83 @@ 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 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 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. """ - required = dict(DEV_BASE_SKILLS) problems: list[Finding] = [] 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}, + ) + ) + for skill, markers in REVIEW_HUNTER_SKILLS.items(): skill_dir = project / tree / skill if not (skill_dir / "SKILL.md").is_file(): problems.append( @@ -202,6 +348,66 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: 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. + + Both conditions are things a run can survive but an operator wants named: + + - ``skills.dev-renderer``: SKILL.md is a renderer stub (BMAD-METHOD #2601) but + the project has no ``_bmad/scripts/render_skill.py``. The session would HALT + before writing anything. Only the script's presence is probed — not uv on + PATH — so this stays a warning rather than a FAIL. + - ``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). + + Returns [] when nothing resolves — :func:`missing_base_skills` owns that story. + """ + findings: list[Finding] = [] + resolved_new = False + for tree in dict.fromkeys(trees): + resolved = resolve_dev_primitive(project, tree) + if resolved is None: + continue + resolved_new = resolved_new or resolved == DEV_PRIMITIVE_NEW + try: + skill_md = (project / tree / resolved / "SKILL.md").read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + # An unreadable/binary SKILL.md cannot be shown to be a renderer stub; + # missing_base_skills has already spoken about this tree's health. + continue + if RENDERER_SCRIPT_MARKER in skill_md and not (project / RENDERER_SCRIPT_REL).is_file(): + findings.append( + Finding( + "skills.dev-renderer", + "warning", + f"{tree}/{resolved}/SKILL.md renders via {RENDERER_SCRIPT_MARKER} but " + f"{RENDERER_SCRIPT_REL} is missing — the session would HALT without " + f"writing a spec; reinstall the BMad Method (bmm) module", + {"tree": tree, "skill": resolved, "script": RENDERER_SCRIPT_REL}, + ) + ) + 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() @@ -587,9 +793,9 @@ def provision_worktree( _copy_traversable(skills_root.joinpath(skill), dst) # 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. Skip silently + # when the main repo lacks them — the run-start preflight reports it. for skill in BASE_SKILLS: dst = tree_dir / skill if dst.exists(): diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index 39726b54..0e7ac228 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" diff --git a/tests/conftest.py b/tests/conftest.py index 274fc56e..befff430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -272,14 +272,89 @@ 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" +) + + +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. 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" + ) + 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 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 59cdf33a..a2fc0ca9 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 @@ -1549,9 +1550,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 +1565,30 @@ 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 _stub_run_tui(monkeypatch): @@ -3402,6 +3426,90 @@ 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_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_warns_on_a_renderer_stub_and_an_orphaned_customize_file( + project, capsys, monkeypatch +): + """Both dev-primitive warnings are advisory: they ride along in the document + and must NOT flip the verdict on their own.""" + 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) + assert doc["ok"] is True # warnings do not fail the run + found = _findings_by_check(doc) + assert found["skills.dev-renderer"]["severity"] == "warning" + assert found["skills.customize-legacy"]["severity"] == "warning" + assert "_bmad/scripts/render_skill.py" in found["skills.dev-renderer"]["message"] + rendered = _render_findings(doc) # both new detail shapes draw, plus skills.base's + assert "skills.dev-renderer" in rendered and "skills.customize-legacy" in rendered + + 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) diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index b41d3f81..f321fe77 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -302,11 +302,14 @@ def test_find_artifact_missing_dir(tmp_path): assert devcontract.find_result_artifact(tmp_path / "ghost", since_ns=0) is None -def test_find_artifact_accepts_no_spec_fallback_prefix(tmp_path): +@pytest.mark.parametrize("prefix", ["bmad-build-auto-result-", "bmad-dev-auto-result-"]) +def test_find_artifact_accepts_no_spec_fallback_prefix(tmp_path, prefix): # The no-spec fallback (intent too unclear to create a spec) carries a terminal # frontmatter status but NO `## Auto Run Result` heading — it is matched by its - # `bmad-dev-auto-result-` filename prefix instead. - fallback = tmp_path / "bmad-dev-auto-result-unclear-1234.md" + # `-result-` filename prefix instead. BOTH eras are matched: the + # artifact is named after whichever skill wrote it (BMAD-METHOD #2651 renamed + # bmad-dev-auto to bmad-build-auto), and a run can meet either. + fallback = tmp_path / f"{prefix}unclear-1234.md" fallback.write_text( "---\nstatus: blocked\n---\n\nBlocking condition: unclear intent\n", encoding="utf-8", diff --git a/tests/test_install.py b/tests/test_install.py index 686e2e56..d11740a6 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,4 +1,5 @@ import json +import shutil import sys import zipfile @@ -600,9 +601,13 @@ def test_provision_worktree_copies_base_skills_from_repo(tmp_path): 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,33 +617,348 @@ 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 - import shutil as _shutil - - _shutil.rmtree(tmp_path / claude.skill_tree / "bmad-review-verification-gap") + 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_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_dev_primitive_warnings_flag_a_renderer_stub_without_its_script(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 — but it + is a warning, not a FAIL: only the script's presence is probed, never uv.""" + from conftest import install_build_auto_skill + + from bmad_loop.checks import VALIDATE_CHECKS + from bmad_loop.install import RENDERER_SCRIPT_REL, dev_primitive_warnings + + tree = get_profile("claude").skill_tree + # an inline (non-stub) SKILL.md never warns, script or no script + install_build_auto_skill(tmp_path, tree) + assert dev_primitive_warnings(tmp_path, [tree]) == [] + + install_build_auto_skill(tmp_path, tree, renderer_stub=True) + warnings = dev_primitive_warnings(tmp_path, [tree]) + assert [f.check for f in warnings] == ["skills.dev-renderer"] + assert warnings[0].severity == "warning" + assert warnings[0].check in VALIDATE_CHECKS + assert RENDERER_SCRIPT_REL in warnings[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 dev_primitive_warnings(tmp_path, [tree]) == [] + + +def test_dev_primitive_warnings_flag_an_orphaned_legacy_customize_file(tmp_path): + """The rename does not migrate `_bmad/custom/.toml`, so an upgraded + project silently stops applying its overrides.""" + from conftest import install_build_auto_skill + + from bmad_loop.install import CUSTOMIZE_DIR_REL, dev_primitive_warnings + + tree = get_profile("claude").skill_tree + custom = tmp_path / CUSTOMIZE_DIR_REL + custom.mkdir(parents=True, exist_ok=True) + (custom / "bmad-dev-auto.toml").write_text("x\n", encoding="utf-8") + + # a PRE-rename project: that file is the correct one — warning here would be + # noise on every legacy install (ablation: drop the resolved-is-new guard) + _install_legacy_primitive(tmp_path, tree) + assert dev_primitive_warnings(tmp_path, [tree]) == [] + + install_build_auto_skill(tmp_path, tree) + warnings = dev_primitive_warnings(tmp_path, [tree]) + assert [f.check for f in warnings] == ["skills.customize-legacy"] + assert warnings[0].severity == "warning" + assert warnings[0].detail["files"] == [f"{CUSTOMIZE_DIR_REL}/bmad-dev-auto.toml"] + + # a counterpart under the new name means the operator already migrated + (custom / "bmad-build-auto.toml").write_text("x\n", encoding="utf-8") + assert dev_primitive_warnings(tmp_path, [tree]) == [] + + # .user.toml is tracked separately — migrating one does not cover the other + (custom / "bmad-dev-auto.user.toml").write_text("x\n", encoding="utf-8") + warnings = dev_primitive_warnings(tmp_path, [tree]) + assert warnings[0].detail["files"] == [f"{CUSTOMIZE_DIR_REL}/bmad-dev-auto.user.toml"] + + +def test_dev_primitive_warnings_are_silent_when_nothing_resolves(tmp_path): + """missing_base_skills owns the unresolvable story; the warnings must not pile + advisory noise on top of a hard preflight failure. + + Both warning sites are armed here — an orphaned customize file AND a shim whose + SKILL.md is a renderer stub — so the silence is the resolution guard's doing and + not merely the absence of anything to say.""" + 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" + + +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 whole-dir, so review-prompts/ (which customize.toml's + layers point at) has to ride along — a markers-only copy would leave every + review layer unresolvable inside the worktree.""" + 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) + + 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): from bmad_loop.install import ( STORIES_PROBE_FILE, @@ -677,8 +997,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 +1008,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 +1071,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 … From 2b1a150e7d983b2ffdf58f1f7d9d342e32217265 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 30 Jul 2026 15:43:09 -0700 Subject: [PATCH 02/41] fix(engine): drive the resolved dev primitive in all session prompts (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 resolved the dev primitive on disk (bmad-build-auto, else a marker-complete bmad-dev-auto) but every session prompt still spelled the pre-rename name, so an upgraded target project would dispatch a slash command that no longer exists. New `Engine._dev_skill(role="dev")` resolves the invoked NAME from the role's adapter `profile.skill_tree` via `install.dev_primitive_or_default`, memoized per tree — one run can mix trees (dev=claude reads .claude/skills, review=codex reads .agents/skills) and the two can sit on different upstream eras. An adapter with no profile yields tree None and the legacy name, which keeps the existing suite and any resolution-failure path dispatching something that exists. `policy.dev.skill` is untouched: it stays the adapter discriminator `_generic_dev` reads; only the spelled name moves. Threaded through every prompt site: `_review_prompt` (via the REVIEW adapter's tree), the workflow completion-marker filename, all three `_generic_dev_prompt` legs, `StoriesEngine`'s folder+id dispatch + repair leg, and `SweepEngine`'s three bundle legs. `cli._dev_skill_for_role` gives both dry-run previews the same resolution, so a preview cannot promise a dispatch `run` would not make. Tests pin the post-rename spelling on every leg, the per-role/per-tree split (one preview legitimately shows both eras), the profile-less legacy fallback, and the marker prefix. Ablating `_dev_skill` to the hardcoded legacy name fails all five engine-side tests; ablating `_dev_skill_for_role` fails both CLI ones. --- src/bmad_loop/cli.py | 25 ++++++++-- src/bmad_loop/engine.py | 47 +++++++++++++++---- src/bmad_loop/stories_engine.py | 10 ++-- src/bmad_loop/sweep.py | 6 +-- tests/conftest.py | 16 +++++++ tests/test_cli.py | 41 ++++++++++++++++ tests/test_engine.py | 83 +++++++++++++++++++++++++++++++++ tests/test_plugin_workflows.py | 26 +++++++++++ tests/test_stories_engine.py | 21 +++++++++ tests/test_sweep.py | 31 ++++++++++++ 10 files changed, 289 insertions(+), 17 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index d73df9a4..3302ba84 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -869,6 +869,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 @@ -922,10 +938,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 @@ -964,13 +982,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)}") diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 690677f5..d319b71e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -30,7 +30,7 @@ decide_review_session, preference_escalations, ) -from .install import provision_worktree +from .install import dev_primitive_or_default, provision_worktree from .journal import Journal, save_state from .model import ( PAUSE_EPIC_BOUNDARY, @@ -144,8 +144,9 @@ def render(self) -> str: # *infer* the completion-marker convention, and one that finishes its work but # never writes the marker leaves the orchestrator waiting (a completion-signal # livelock, bounded only by session_timeout_min). The orchestrator's adapter -# discovers the marker by its `bmad-dev-auto-result-` filename prefix and -# mtime, not by exact name. +# discovers the marker by its `-result-` filename prefix and +# mtime, not by exact name (devcontract.FALLBACK_RESULT_PREFIXES accepts both +# the pre- and post-rename spellings, so either resolution reads back). WORKFLOW_COMPLETION_CONTRACT = """ ## Completion signal (required) @@ -260,6 +261,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 @@ -2187,6 +2193,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 @@ -2442,7 +2472,7 @@ def _review_prompt(self, task: StoryTask) -> str: # ledger is append-only for sessions — new findings are fine, existing # entries are orchestrator-owned. return ( - f"/bmad-dev-auto {task.spec_file} — If this review defers new " + f"/{self._dev_skill('review')} {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." @@ -2578,7 +2608,8 @@ def _run_session( # searches — correct in place and under worktree isolation alike, # because spec.cwd is self.workspace.root either way. marker_path = ( - self.workspace.paths.implementation_artifacts / f"bmad-dev-auto-result-{task_id}.md" + self.workspace.paths.implementation_artifacts + / f"{self._dev_skill()}-result-{task_id}.md" ) prompt += WORKFLOW_COMPLETION_CONTRACT.format(marker_path=marker_path) spec = SessionSpec( @@ -2736,16 +2767,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 " diff --git a/src/bmad_loop/stories_engine.py b/src/bmad_loop/stories_engine.py index 688fd0e9..83e2133d 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -346,7 +346,8 @@ def _stories_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: repair leg falls back to the inherited explicit-spec-file resume. Fresh dispatch: - ``/bmad-dev-auto Spec folder: . Story id: .`` + ``/ Spec folder: . Story id: .`` + (the primitive is disk-resolved — see ``Engine._dev_skill``) + (plan-halt leg) `` Halt after planning.`` + (when ``invoke_dev_with`` non-empty) a newline then its verbatim text. @@ -361,14 +362,17 @@ def _stories_dev_prompt(self, task: StoryTask, feedback: Path | None) -> str: self._reset_spec_for_repair(task) spec_ref = task.spec_file or task.story_key return ( - f"/bmad-dev-auto Resume the autonomous dev session on the in-progress " + f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress " f"spec at `{spec_ref}`. The previous session's work failed deterministic " f"verification; repair the working tree so verification passes without " f"changing the spec's frozen intent contract. Verification evidence is " f"in `{feedback}`." ) entry = self._entry_for(task) - prompt = f"/bmad-dev-auto Spec folder: {self._spec_folder_rel}. Story id: {task.story_key}." + prompt = ( + f"/{self._dev_skill()} Spec folder: {self._spec_folder_rel}. " + f"Story id: {task.story_key}." + ) if self._plan_halt_leg(task, entry): prompt += " Halt after planning." if entry is not None and entry.invoke_dev_with: diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index b5e681a2..f3913e5a 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1172,7 +1172,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 +1180,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 +1188,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 " diff --git a/tests/conftest.py b/tests/conftest.py index befff430..ea9745aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -355,6 +355,22 @@ def install_dev_shim(root: Path, tree: str = ".claude/skills") -> Path: 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index a2fc0ca9..8b1bc4ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3947,3 +3947,44 @@ def test_platform_preflight_notes_forced_selection_provenance(mux_registry, monk mux_registry.get_multiplexer.cache_clear() notes, problems = _preflight_notes_problems() assert any("forced by BMAD_LOOP_MUX_BACKEND" in n for n in notes) + + +def test_dry_run_previews_the_disk_resolved_primitive_per_role(project, capsys): + """#405: the preview must spell what `run` would actually dispatch, resolved + per role from that adapter's skill tree. dev=claude reads .claude/skills + (post-rename here) and review=codex reads .agents/skills (still pre-rename), + so one honest preview shows both eras side by side. The all-legacy default is + pinned by test_dry_run_renders_per_stage_commands, which installs no skills.""" + from conftest import install_build_auto_skill, install_dev_base_skills + + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + install_build_auto_skill(project.project, ".claude/skills") + install_dev_base_skills(project.project, ".agents/skills", folder_id=False) + _write_policy(project.project) + pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") + args = argparse.Namespace(epic=None, story=None, max_stories=None) + + assert cli._dry_run(project, pol, args) == 0 + out = capsys.readouterr().out + dev_line = next(line for line in out.splitlines() if "dev:" in line) + review_line = next(line for line in out.splitlines() if "review:" in line) + assert "/bmad-build-auto 1-1-a" in dev_line + # codex's prompt_template rewrites "/skill args" into "$skill ... args" + assert "$bmad-dev-auto" in review_line and "" in review_line + assert "bmad-build-auto" not in review_line + + +def test_dry_run_stories_previews_the_resolved_primitive(project, capsys): + """The folder+id dispatch preview follows the same resolution.""" + from conftest import install_build_auto_skill + + _setup_stories_fixture(project, [_stories_entry("1")]) + install_build_auto_skill(project.project, ".claude/skills") + _write_policy(project.project) + pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") + args = argparse.Namespace(spec=STORIES_SPEC_FOLDER, epic=None, story=None, max_stories=None) + + assert cli._dry_run(project, pol, args, True, STORIES_SPEC_FOLDER) == 0 + out = capsys.readouterr().out + assert "/bmad-build-auto Spec folder: _bmad-output/epic-1. Story id: 1." in out + assert "/bmad-dev-auto Spec folder:" not in out diff --git a/tests/test_engine.py b/tests/test_engine.py index 155a7c12..1a3414d6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5497,3 +5497,86 @@ 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", + } diff --git a/tests/test_plugin_workflows.py b/tests/test_plugin_workflows.py index e3d78fec..b4110110 100644 --- a/tests/test_plugin_workflows.py +++ b/tests/test_plugin_workflows.py @@ -440,3 +440,29 @@ 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.""" + 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 diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 23ab58fc..19946358 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -1220,3 +1220,24 @@ 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" + ) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 35519485..cccdc7b2 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -2303,3 +2303,34 @@ 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" + ) From 9754f842f65ff7033a5b51f226398ed6e4b8079a Mon Sep 17 00:00:00 2001 From: t Date: Thu, 30 Jul 2026 15:56:46 -0700 Subject: [PATCH 03/41] fix(cli): warn when a dry-run's preview is not actually runnable (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmd_run` returns from `_dry_run` at cli.py:799 — before `_require_base_skills` at :820 — and `cmd_sweep` has the same shape (:1106 before :1113), so a project whose skills are broken still gets a plausible-looking preview at rc 0. Pre-rename that was merely incomplete: the previewed `/bmad-dev-auto` named a skill that did not exist, and the operator would find out loudly. Post-rename it is misleading, because upstream leaves a forwarding SHIM behind — the slash command resolves, so the preview reads fine and the dispatch it promises would HALT an unattended session on the shim's interactive migration gate. Found by the sandbox E2E, not by the unit suite. All three dry-run entry points now run the same skill probes the real preflight runs and print the resulting FAILs to stderr under a "NOT runnable as-is" banner. The probe lookup moves into a shared `_skill_trees`, so the banner and `_require_base_skills` cannot drift apart — a preview that claims run would abort must key on exactly what makes run abort. The exit code stays 0 on purpose. A dry-run is a diagnostic: refusing to print the schedule would withhold what the operator asked for, and existing callers read rc 0 as "the preview rendered", not as "the project is ready". stdout is untouched, so the schedule stays parseable. Tests cover all three entry points plus the negative — a complete install prints nothing to stderr — so the banner cannot be unconditional and still pass. Removing the three calls fails the three positive tests and leaves the negative one green. --- src/bmad_loop/cli.py | 60 +++++++++++++++++++++++++++++----- tests/test_cli.py | 76 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 3302ba84..23337179 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -643,6 +643,53 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: return 0 +def _skill_trees(project: Path, pol) -> list[str]: + """The skill trees this run's adapters read, one per distinct adapter name. + + Shared by the real preflight and the dry-run banner so the two cannot drift: + a preview that claims "run would abort" has to key on exactly what makes run + abort. 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.""" + from .adapters.profile import ProfileError, get_profile + + trees = [] + for name in dict.fromkeys(pol.adapter.resolved(role).name for role in ROLES): + try: + trees.append(get_profile(name, project).skill_tree) + except ProfileError: + continue + return trees + + +def _warn_preflight_would_abort(project: Path, 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. + + 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(project, pol) + problems = install.missing_base_skills(project, trees) + if require_stories: + problems += install.missing_stories_support(project, trees) + 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.message}", 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 @@ -658,14 +705,7 @@ def _require_base_skills(project: Path, pol, *, require_stories: bool = False) - 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.""" - from .adapters.profile import ProfileError, get_profile - - skill_trees = [] - for name in dict.fromkeys(pol.adapter.resolved(role).name for role in ROLES): - try: - skill_trees.append(get_profile(name, project).skill_tree) - except ProfileError: - continue + 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) @@ -922,6 +962,8 @@ def _dry_run( if stories_on: return _dry_run_stories(paths, pol, args, spec_folder) + _warn_preflight_would_abort(paths.project, pol) + def render(role: str, prompt: str) -> str: return _render_invocation(pol, paths.project, role, prompt) @@ -964,6 +1006,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.project, 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. @@ -1130,6 +1173,7 @@ def cmd_sweep(args: argparse.Namespace) -> int: def _sweep_dry_run(paths: bmadconfig.ProjectPaths, pol) -> int: + _warn_preflight_would_abort(paths.project, pol) ledger = paths.deferred_work if not ledger.is_file(): print(f"no deferred-work ledger at {ledger}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 8b1bc4ef..b15d8261 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3988,3 +3988,79 @@ def test_dry_run_stories_previews_the_resolved_primitive(project, capsys): 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 _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_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 From 10ce26c6a9b6e9e98676b20536263bb94a7923c1 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 30 Jul 2026 16:17:11 -0700 Subject: [PATCH 04/41] fix(engine): harvest spec-frontmatter deferrals into the ledger (#405) --- src/bmad_loop/deferredwork.py | 12 +- src/bmad_loop/devcontract.py | 108 +++++++++++ src/bmad_loop/engine.py | 151 ++++++++++++++++ tests/conftest.py | 78 +++++++- tests/test_deferredwork.py | 38 ++++ tests/test_devcontract.py | 210 ++++++++++++++++++++++ tests/test_engine.py | 330 ++++++++++++++++++++++++++++++++++ tests/test_stories_engine.py | 74 +++++++- tests/test_sweep.py | 59 ++++++ 9 files changed, 1046 insertions(+), 14 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 6c90aaf3..28655973 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -146,6 +146,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 +154,14 @@ 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 optional file:line/component field (documented in + deferred-work-format.md, already read by the TUI and the sweep triage); it is + written directly after `source_spec:` so the emitted order matches the + canonical origin → location → severity → reason → status shape. Omitted + entirely when empty — a `location: n/a` line would read as an assertion the + finding has no location, which is not what an absent field means.""" text = path.read_text(encoding="utf-8") if path.is_file() else "" for entry in parse_ledger(text): if ( @@ -164,6 +172,8 @@ 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}`"] + if location: + lines.append(f"location: {location}") 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 b2149427..b960f060 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), @@ -168,6 +170,112 @@ 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".""" + if value is None: + return "" + return " ".join(str(value).split())[:limit] + + +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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index d319b71e..6a600fd7 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -59,6 +59,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): @@ -1762,6 +1769,12 @@ 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. + 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 @@ -1956,6 +1969,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)) @@ -2420,6 +2440,137 @@ 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 _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, so a story that never lands cannot seed the ledger + with findings about work that was rolled back. + + 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 a + ledger-side watermark rolls back atomically with the entries it guards + (`git reset` on a defer takes both, or neither). 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) + 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]: diff --git a/tests/conftest.py b/tests/conftest.py index ea9745aa..200fb5e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -399,15 +399,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 @@ -478,6 +533,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 @@ -495,7 +551,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) @@ -505,7 +565,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", @@ -652,6 +712,7 @@ def bundle_dev_effect( followup_review: bool = True, final_status: str = "done", prose_status: str | None = None, + deferred=None, ): """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 @@ -659,8 +720,9 @@ 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`` 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.""" def effect(spec: SessionSpec) -> SessionResult: baseline = rev_parse_head(paths.project) @@ -668,7 +730,7 @@ def effect(spec: SessionSpec) -> SessionResult: 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_deferredwork.py b/tests/test_deferredwork.py index ffa89906..31ae46d6 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -450,6 +450,44 @@ 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_omits_location_when_absent(tmp_path): + """An entry with no location carries no `location:` line at all. Writing a + placeholder would assert the finding *has* no location, which is a different + claim from the skill simply not recording one.""" + p = tmp_path / "deferred-work.md" + append_entry(p, title="t", origin="o", source_spec="s.md", reason="r") + assert "location:" not in p.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 f321fe77..1a4be160 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( @@ -718,3 +719,212 @@ 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_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_engine.py b/tests/test_engine.py index 1a3414d6..68fca0dc 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5580,3 +5580,333 @@ def test_review_prompt_resolves_through_the_review_adapters_own_tree(project): ".claude/skills": "bmad-build-auto", ".agents/skills": "bmad-dev-auto", } + + +# ------------------------- 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 then defers rolls the ledger edit back + with the work it describes, and a story that proceeds carries it into the + squashed commit.""" + 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_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() diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 19946358..9ff09c8d 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -63,7 +63,11 @@ 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, ): """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 @@ -78,7 +82,7 @@ def effect(spec) -> SessionResult: 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) + write_spec(sp, final_status, baseline, prose_status=prose_status, deferred=deferred) return SessionResult( status="completed", result_json={ @@ -97,7 +101,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 +124,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}, @@ -1241,3 +1245,63 @@ def test_dev_prompt_spells_the_post_rename_primitive(project): 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"] diff --git a/tests/test_sweep.py b/tests/test_sweep.py index cccdc7b2..1020ca42 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -2334,3 +2334,62 @@ def test_generic_bundle_prompt_spells_the_post_rename_primitive(project): 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) From dec2cab73c8ba52c18815f145eae6ac6f90ba3c3 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 16:24:05 -0700 Subject: [PATCH 05/41] =?UTF-8?q?chore(release):=200.9.1=20=E2=80=94=20Com?= =?UTF-8?q?patibility=20hotfix=20for=20the=20BMad=20Method's=20`bmad-dev-a?= =?UTF-8?q?uto`=20=E2=86=92=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 50 +++++++++++++++++++ module.yaml | 2 +- pyproject.toml | 2 +- src/bmad_loop/__init__.py | 2 +- .../skills/bmad-loop-setup/assets/module.yaml | 2 +- uv.lock | 2 +- 7 files changed, 56 insertions(+), 6 deletions(-) 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..c99d6cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ 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-07-30 + +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; nothing in `policy.toml` needs +editing. + +### Added + +- **Two validate warnings for post-rename installs (#405).** `skills.dev-renderer` fires when + the resolved `SKILL.md` is the new renderer stub (BMAD-METHOD#2601) but + `_bmad/scripts/render_skill.py` is missing — that session would HALT without writing a spec. + `skills.customize-legacy` 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. + +### 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". + +- **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 retry, + a resume replay or a second review pass never re-files one — including after a sweep has + already marked it done. 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. + ## [0.9.0] — 2026-07-21 ### Added @@ -1691,6 +1740,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/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/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/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'" }, From 8eb05b282750bebea580a8296b11c7e8cfd1dcb6 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 18:09:23 -0700 Subject: [PATCH 06/41] fix(engine): stop the review prompt from double-filing harvested deferrals (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review prompt told the session to "append them to the deferred-work ledger as NEW entries only". Post-BMAD-METHOD#2640 the primitive already records its `defer` findings in the spec's frontmatter, and `_harvest_spec_deferrals` files those into the same ledger — so every finding landed twice, and the pair can never dedup: `append_entry` matches on `origin:` + `source_spec:` and an agent-written entry carries neither. Two ledger entries, two sweep bundles, one finding. Drop the affirmative clause and keep the prohibition on rewriting existing entries (the prevention side of SweepEngine._verify_review's reclose). Deliberately neutral rather than the sweep prompts' outright "do NOT edit the ledger": on a pre-#2640 skill there is no frontmatter to harvest and the session's own flat append is the only record, so a ban would lose findings. --- src/bmad_loop/engine.py | 20 +++++++++++++------- tests/test_engine.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 6a600fd7..82522394 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2619,14 +2619,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"/{self._dev_skill('review')} {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: diff --git a/tests/test_engine.py b/tests/test_engine.py index 68fca0dc..59742a28 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5582,6 +5582,29 @@ def test_review_prompt_resolves_through_the_review_adapters_own_tree(project): } +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 From 6b4f4f9d9c77f911dbf8dcbc092053ff6264ae04 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 18:09:36 -0700 Subject: [PATCH 07/41] fix(engine): state the real defer-rollback semantics of the harvest (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harvest docstring claimed the ledger edit "rolls back atomically with the entries it guards (`git reset` on a defer takes both, or neither)". It does not, and the ordering-contract test's docstring repeated the claim. `_defer` snapshots deferred-work.md after the harvest wrote it, resets, then writes the snapshot back — so harvested entries survive a defer by design. That is the correct behaviour, not the bug: `_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. The atomic reading describes the RETRY path, which has no restore. State the asymmetry instead of flattening it, and cover the defer leg — previously untested in either direction. --- src/bmad_loop/engine.py | 20 +++++++++++++----- tests/test_engine.py | 47 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 82522394..726f1cdf 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -2469,11 +2469,21 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> 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 a - ledger-side watermark rolls back atomically with the entries it guards - (`git reset` on a defer takes both, or neither). Ledger writes are - UNGUARDED — observation degrades, repair raises — so a write fault reaches - run()'s crash recorder instead of silently dropping the findings. + `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 RETRY reset (`_rollback_or_pause`, no restore) reverts the ledger edit + along with the work it describes, and the next attempt re-harvests from + the untouched frontmatter. A DEFER does NOT: `_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 diff --git a/tests/test_engine.py b/tests/test_engine.py index 59742a28..fa1cae43 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5679,9 +5679,11 @@ def test_spec_frontmatter_deferrals_harvested_into_ledger(project): 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 then defers rolls the ledger edit back - with the work it describes, and a story that proceeds carries it into the - squashed commit.""" + 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. 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, @@ -5694,6 +5696,45 @@ def test_spec_deferrals_harvested_before_the_dev_decision(project): 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) + + 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.""" From 9ca3ee13599f12cac467fa2f9a81fd3dadbac2a7 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 18:39:23 -0700 Subject: [PATCH 08/41] fix(worktree): seed the _bmad config surface into isolation worktrees (#405) --- src/bmad_loop/install.py | 161 ++++++++++++++++++++++++- tests/test_engine_worktree.py | 52 ++++++++ tests/test_install.py | 217 ++++++++++++++++++++++++++++++++-- 3 files changed, 417 insertions(+), 13 deletions(-) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 11db6f8b..129a8492 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -21,7 +21,7 @@ import subprocess from collections.abc import Iterable, 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 @@ -90,18 +90,31 @@ DEV_PRIMITIVE_LEGACY = "bmad-dev-auto" DEV_PRIMITIVE_MARKERS = ("step-04-review.md", "customize.toml") +# 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 validate warns — # advisory, not a FAIL: only the script's presence is probed, never uv on PATH. -RENDERER_SCRIPT_REL = "_bmad/scripts/render_skill.py" +RENDERER_SCRIPT_REL = f"{BMAD_DIR}/scripts/render_skill.py" RENDERER_SCRIPT_MARKER = "render_skill.py" +# 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. +BMAD_SEED_EXCLUDES = ("render",) + # 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 = "_bmad/custom" +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 @@ -640,6 +653,104 @@ 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. + + 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 src_root.is_dir(): + return [] + dst_root = worktree / BMAD_DIR + had_bmad = dst_root.is_dir() + seeded: list[str] = [] + for top in sorted(src_root.iterdir()): + if top.name in BMAD_SEED_EXCLUDES: + continue + for src in [top] if top.is_file() else sorted(top.rglob("*")): + if not src.is_file(): + continue + rel = src.relative_to(src_root) + dst = dst_root / rel + if not src.resolve().is_relative_to(repo_root) or not dst.resolve().is_relative_to( + worktree + ): + continue + if dst.exists(): + continue + dst.parent.mkdir(parents=True, exist_ok=True) + _copy_traversable(src, dst) + seeded.append((Path(BMAD_DIR) / rel).as_posix()) + 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. Measured: a symlinked dir IS walked when it is the rglob root (which it + is in :func:`_seed_bmad_tree`), 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. + + Reported through :func:`provision_worktree`'s existing skipped-seed return + channel (the caller journals ``worktree-seed-skipped``) rather than raising: + an incomplete seed is a degraded provision, not a failed one, and that channel + needs no engine or journal-schema change. + """ + if not (repo_root / RENDERER_SCRIPT_REL).is_file(): + return False + scripts = repo_root / BMAD_DIR / "scripts" + dst_scripts = worktree / BMAD_DIR / "scripts" + return any( + src.is_file() and not (dst_scripts / src.relative_to(scripts)).is_file() + for src in scripts.rglob("*") + ) + + def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool: """Install the bundled bmad-loop-* skills into each project skill tree. @@ -726,12 +837,21 @@ 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 is additionally excluded whenever the worktree has a _bmad/ at all, + so the renderer's in-session rewrite of it can't be 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 an INCOMPLETE `_bmad/scripts/` seed (see + _bmad_scripts_seed_incomplete), which is likewise silent otherwise. """ - 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() @@ -782,6 +902,18 @@ def provision_worktree( # 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 documented `worktree_seed = ["_bmad"]` as the workaround for exactly + # 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 applied. + skipped = [rel for rel in skipped if not _is_under_bmad(rel)] + if _bmad_scripts_seed_incomplete(worktree, repo_root): + skipped.append(f"{BMAD_DIR}/scripts") + # 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). for tree in dict.fromkeys(p.skill_tree for p in profiles): @@ -836,6 +968,14 @@ 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 (worktree / BMAD_DIR).is_dir(): + # Shielded whether we seeded it or not: the renderer rewrites this dir + # DURING the session, long after provisioning. Gated on the worktree + # actually having a _bmad/ because .git/info/exclude is the COMMON git dir, + # shared with the main checkout — a line for a dir this worktree can never + # grow is pollution in the operator's own repo. + patterns.add(f"/{BMAD_DIR}/render/") _worktree_local_exclude(worktree, sorted(patterns)) return skipped @@ -933,7 +1073,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"{BMAD_DIR}/render/", + ) if line not in have ] if to_add: diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index e3ccac4b..6e030f9f 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -670,6 +670,58 @@ 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/")] + + # ----------------------------------------------------------------- new guards (review hardening) diff --git a/tests/test_install.py b/tests/test_install.py index d11740a6..7380eb02 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -417,6 +417,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" @@ -432,6 +435,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): @@ -1144,15 +1148,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): @@ -1309,6 +1318,200 @@ def test_provision_worktree_seed_globs_shielded_in_local_exclude(project, tmp_pa assert git(wt, "status", "--short", "--", ".claude/skills/tests-run") == "" +# ----------------------------------------------------------------- _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" + + +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.""" + 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/` is shielded whenever the worktree has a `_bmad/` — the + renderer rewrites that dir mid-session, long after provisioning. It is NOT + written when there is no `_bmad/` at all: `.git/info/exclude` is the COMMON git + dir, shared with the main checkout, so an ungated line is pollution there.""" + 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) + wt = tmp_path / "wt" + verify.worktree_add(repo, wt, "feat", "main") + + provision_worktree(wt, [get_profile("claude")], repo) + + assert "/_bmad/render/" in exclude_path.read_text(encoding="utf-8").splitlines() + + +@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() + + +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) From 377fcac7ee9e7c89a3eff459dc7d5d06ff4e14c4 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 20:32:07 -0700 Subject: [PATCH 09/41] feat(validate): warn when the renderer stub has no central _bmad/config.toml (#405) --- src/bmad_loop/checks.py | 1 + src/bmad_loop/install.py | 39 ++++++++++++++++++-- tests/test_cli.py | 12 +++++-- tests/test_install.py | 78 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index da1c3591..fdf9f7e1 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -61,6 +61,7 @@ "skills.base-incomplete", "skills.base-shim", "skills.dev-renderer", + "skills.dev-renderer-config", "skills.customize-legacy", "skills.stories-dispatch", "skills.stories-dispatch-missing", diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 129a8492..f26eae92 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -103,6 +103,14 @@ RENDERER_SCRIPT_REL = f"{BMAD_DIR}/scripts/render_skill.py" RENDERER_SCRIPT_MARKER = "render_skill.py" +# The bottom layer of the renderer's four-layer central config, and the only +# REQUIRED one (`config_utils.load_central_config` passes required=True; 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. Project-global, not per tree. +CENTRAL_CONFIG_REL = f"{BMAD_DIR}/config.toml" + # 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 @@ -364,21 +372,34 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding]: """Advisory findings about a resolved dev primitive — validate-only, never a gate. - Both conditions are things a run can survive but an operator wants named: + All three conditions are things a run can survive but an operator wants named: - ``skills.dev-renderer``: SKILL.md is a renderer stub (BMAD-METHOD #2601) but the project has no ``_bmad/scripts/render_skill.py``. The session would HALT before writing anything. Only the script's presence is probed — not uv on PATH — so this stays a warning rather than a FAIL. + - ``skills.dev-renderer-config``: a stub resolved somewhere but the project has + no ``_bmad/config.toml``, the renderer's one required config layer. Same + result-less HALT, one file further in — and no other check sees it, because + the failure is a ``ConfigError`` inside a subprocess the orchestrator only + observes as a Stop with no artifacts. Emitted once per project (the central + config is project-global, not per tree) and gated on a stub having resolved, + which keeps the check 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.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). + Script-missing and config-missing are deliberately NOT suppressed against each + other: different files, different remediations, and a wholly-absent ``_bmad/`` + legitimately earns both lines. + Returns [] when nothing resolves — :func:`missing_base_skills` owns that story. """ findings: list[Finding] = [] resolved_new = False + resolved_stub = False for tree in dict.fromkeys(trees): resolved = resolve_dev_primitive(project, tree) if resolved is None: @@ -390,7 +411,10 @@ def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding] # An unreadable/binary SKILL.md cannot be shown to be a renderer stub; # missing_base_skills has already spoken about this tree's health. continue - if RENDERER_SCRIPT_MARKER in skill_md and not (project / RENDERER_SCRIPT_REL).is_file(): + if RENDERER_SCRIPT_MARKER not in skill_md: + continue + resolved_stub = True + if not (project / RENDERER_SCRIPT_REL).is_file(): findings.append( Finding( "skills.dev-renderer", @@ -401,6 +425,17 @@ def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding] {"tree": tree, "skill": resolved, "script": RENDERER_SCRIPT_REL}, ) ) + if resolved_stub and not (project / CENTRAL_CONFIG_REL).is_file(): + findings.append( + Finding( + "skills.dev-renderer-config", + "warning", + 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}, + ) + ) if resolved_new: orphaned = [ f"{CUSTOMIZE_DIR_REL}/{DEV_PRIMITIVE_LEGACY}{suffix}" diff --git a/tests/test_cli.py b/tests/test_cli.py index b15d8261..872a08c2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3487,8 +3487,12 @@ def test_validate_reports_the_shim_as_a_problem(project, capsys, monkeypatch): def test_validate_warns_on_a_renderer_stub_and_an_orphaned_customize_file( project, capsys, monkeypatch ): - """Both dev-primitive warnings are advisory: they ride along in the document - and must NOT flip the verdict on their own.""" + """All three dev-primitive warnings are advisory: they ride along in the + document and must NOT flip the verdict on their own. + + 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 @@ -3504,10 +3508,12 @@ def test_validate_warns_on_a_renderer_stub_and_an_orphaned_customize_file( assert doc["ok"] is True # warnings do not fail the run found = _findings_by_check(doc) assert found["skills.dev-renderer"]["severity"] == "warning" + assert found["skills.dev-renderer-config"]["severity"] == "warning" # 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) # both new detail shapes draw, plus skills.base's + 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_json_clean_project_is_a_pure_document_at_rc_0(project, capsys, monkeypatch): diff --git a/tests/test_install.py b/tests/test_install.py index 7380eb02..2a476efb 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -828,9 +828,13 @@ def test_dev_primitive_warnings_flag_a_renderer_stub_without_its_script(tmp_path from conftest import install_build_auto_skill from bmad_loop.checks import VALIDATE_CHECKS - from bmad_loop.install import RENDERER_SCRIPT_REL, dev_primitive_warnings + from bmad_loop.install import CENTRAL_CONFIG_REL, RENDERER_SCRIPT_REL, dev_primitive_warnings tree = get_profile("claude").skill_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 warns, script or no script install_build_auto_skill(tmp_path, tree) assert dev_primitive_warnings(tmp_path, [tree]) == [] @@ -849,6 +853,78 @@ def test_dev_primitive_warnings_flag_a_renderer_stub_without_its_script(tmp_path assert dev_primitive_warnings(tmp_path, [tree]) == [] +def test_dev_primitive_warnings_config_warning_when_central_toml_absent(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 warning 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, dev_primitive_warnings + + tree = get_profile("claude").skill_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 dev_primitive_warnings(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") + warnings = dev_primitive_warnings(tmp_path, [tree]) + assert [f.check for f in warnings] == ["skills.dev-renderer-config"] + assert warnings[0].severity == "warning" + assert warnings[0].check in VALIDATE_CHECKS + assert CENTRAL_CONFIG_REL in warnings[0].message + assert warnings[0].detail["config"] == CENTRAL_CONFIG_REL + + # …and it clears once the layer is there (ablation: drop the is_file predicate) + (tmp_path / CENTRAL_CONFIG_REL).write_text('[core]\nname = "x"\n', encoding="utf-8") + assert dev_primitive_warnings(tmp_path, [tree]) == [] + + +def test_dev_primitive_warnings_config_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, dev_primitive_warnings + + claude, codex = get_profile("claude").skill_tree, get_profile("codex").skill_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 dev_primitive_warnings(tmp_path, [claude, codex]) == [] + + +def test_dev_primitive_warnings_config_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, dev_primitive_warnings + + 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_build_auto_skill(tmp_path, tree, renderer_stub=True) + + warnings = dev_primitive_warnings(tmp_path, [claude, codex]) + assert [f.check for f in warnings] == ["skills.dev-renderer-config"] + + 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 20d3dc05dc14aef94ad1fb0a9094b451abbb412e Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 20:55:35 -0700 Subject: [PATCH 10/41] fix(engine): revert a harvested deferral when its attempt rolls back (#405) --- src/bmad_loop/engine.py | 88 ++++++++++++++-- tests/test_engine.py | 185 +++++++++++++++++++++++++++++++++- tests/test_engine_worktree.py | 64 ++++++++++++ 3 files changed, 326 insertions(+), 11 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 726f1cdf..9b30581e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1726,6 +1726,14 @@ 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: + # Pre-harvest ledger snapshot for the non-fixable-RETRY revert below, + # re-armed every attempt. The flag is separate from the text on + # purpose: `ledger_before is None` means the ledger did NOT EXIST when + # the snapshot was taken (the first-ever harvest creates it, and the + # restore then has to unlink), which is a different state from "no + # snapshot was taken at all" (nothing to restore, hands off). + ledger_before: str | None = None + ledger_snapshotted = False 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 @@ -1773,7 +1781,13 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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. + # 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. Snapshot the + # ledger here so the rollback half is enforced explicitly, at the + # RETRY branch below. + ledger_before = self._ledger_text() + ledger_snapshotted = True 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. @@ -1822,7 +1836,22 @@ 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) + try: + self._rollback_or_pause(task) + finally: + # The reset discards the attempt but NOT its harvest: 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 — the + # next attempt re-harvests from it. + if ledger_snapshotted: + self._restore_ledger(ledger_before) continue if decision.action == Action.DEFER: self._record_dev_spec(task, result.result_json) @@ -2458,8 +2487,11 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> 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, so a story that never lands cannot seed the ledger - with findings about work that was rolled back. + 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 @@ -2473,10 +2505,16 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> fingerprinted `origin:` above already watermarks the ledger side. Rollback is asymmetric between the two failure paths, and deliberately so. - A RETRY reset (`_rollback_or_pause`, no restore) reverts the ledger edit - along with the work it describes, and the next attempt re-harvests from - the untouched frontmatter. A DEFER does NOT: `_defer` snapshots the ledger - and writes it back after the reset, keeping harvested entries. It has to — + 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 + immediately before this call and restores it around `_rollback_or_pause` + — that restore, not the reset, is what makes the revert unconditional. + 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. @@ -3187,6 +3225,40 @@ def _defer(self, task: StoryTask, reason: str) -> None: ) self._save() + 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 _restore_ledger(self, 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. + + 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: + ledger.unlink(missing_ok=True) + return + ledger.parent.mkdir(parents=True, exist_ok=True) + ledger.write_text(snapshot, encoding="utf-8") + 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/tests/test_engine.py b/tests/test_engine.py index fa1cae43..c14a2144 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5681,9 +5681,12 @@ 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. 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.""" + 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, @@ -5735,6 +5738,182 @@ def test_defer_keeps_the_harvested_entry_after_rollback(project): 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 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.""" diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 6e030f9f..5bd67599 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -722,6 +722,70 @@ def dev_and_probe(spec): assert not [f for f in files if f.startswith("_bmad/")] +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) From 54a5d9b45da9892cf6e0e2915d2eee30954e9aa8 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 21:05:25 -0700 Subject: [PATCH 11/41] docs(changelog): cover the review-round fixes in 0.9.1 (#405) Amends the existing (untagged) 0.9.1 section in place rather than cutting a new version: the third validate warning, the _bmad/ worktree seed, the review prompt's dropped append clause, and the harvest's rollback revert. --- CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99d6cf7..d6ca9656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,15 @@ editing. ### Added -- **Two validate warnings for post-rename installs (#405).** `skills.dev-renderer` fires when +- **Three validate warnings for post-rename installs (#405).** `skills.dev-renderer` fires when the resolved `SKILL.md` is the new renderer stub (BMAD-METHOD#2601) but `_bmad/scripts/render_skill.py` is missing — that session would HALT without writing a spec. - `skills.customize-legacy` 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. + `skills.dev-renderer-config` fires when a stub resolved but `_bmad/config.toml` — the + renderer's one required config layer — is absent: the same result-less HALT, one file further + in, and no other check sees it. It is emitted once per project and only when a stub actually + resolved, so a pre-renderer install stays silent. `skills.customize-legacy` 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. ### Fixed @@ -44,6 +48,19 @@ editing. 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". +- **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 — is + reported through the existing `worktree-seed-skipped` journal event instead of passing as + success. `_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"`. + - **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 @@ -53,6 +70,23 @@ editing. already marked it done. 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. A defer still keeps its + harvested entries: `_stash_deferred_artifacts` has already moved the spec out of the artifacts + dir, so there the ledger entry is the finding's only surviving record. ## [0.9.0] — 2026-07-21 From ed2877761371c1cee47c9be14e08de587c62aea7 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 21:56:55 -0700 Subject: [PATCH 12/41] fix(validate): block a run whose renderer stub cannot compose a prompt (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills.dev-renderer` and `skills.dev-renderer-config` shipped in this hotfix as advisory warnings, on the reasoning that only the script's presence is probed and never uv on PATH. That is an argument about a false green, not a false red: absence of `_bmad/scripts/render_skill.py` (or of the renderer's one required config layer) is *sufficient* for the stub's `uv run` to exit `HALT:` and Stop having written no spec — on this story and on every story after it, since both files are project-global. Warning about a run that cannot possibly produce a result was the wrong service. Severity alone could not do it: `_require_base_skills` has no severity filter, it FAILs on whatever `missing_base_skills` returns. So the two emitters move there, ids, messages and `detail` dicts unchanged, with the stub test extracted to `_is_renderer_stub`. `dev_primitive_warnings` keeps only `skills.customize-legacy`, which a session genuinely survives (it just runs unstyled). `run --dry-run`'s preflight banner reads the same list, so it picks both conditions up for free — previously silent. Both ids are new in 0.9.1 and unreleased, so the promotion costs no shipped behaviour: no project that passes `validate` today loses a run it would have completed. Reverses the cut recorded in #410's table, per Codex on #406. --- src/bmad_loop/install.py | 136 ++++++++++++++++++++++----------------- tests/test_cli.py | 124 +++++++++++++++++++++++++++++++++-- tests/test_install.py | 118 +++++++++++++++++++++++---------- 3 files changed, 277 insertions(+), 101 deletions(-) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index f26eae92..9f6e7574 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -98,8 +98,11 @@ # 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 validate warns — -# advisory, not a FAIL: only the script's presence is probed, never uv on PATH. +# 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" @@ -108,7 +111,8 @@ # .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. Project-global, not per tree. +# 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" # Top-level _bmad/ entries never seeded into a worktree. render/ is the renderer's @@ -201,6 +205,22 @@ def _is_dev_primitive_shim(project: Path, tree: str) -> bool: 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 dev_primitive_or_default(project: Path, tree: str | None) -> str: """Total form of :func:`resolve_dev_primitive` for prompt builders. @@ -293,8 +313,25 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: ``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, ``_bmad/scripts/render_skill.py`` + is not. Per tree, since the primitive resolves per tree. + - ``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. + + Both are emitted independently of the marker check above them — different files, + different remediations, and a wholly-absent ``_bmad/`` legitimately earns both + lines beside a truncated skill. """ problems: list[Finding] = [] + resolved_stub = False for tree in dict.fromkeys(trees): resolved = resolve_dev_primitive(project, tree) if resolved is None and _is_dev_primitive_shim(project, tree): @@ -342,6 +379,19 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: {"tree": tree, "skill": resolved, "missing_markers": absent}, ) ) + if _is_renderer_stub(skill_dir): + resolved_stub = True + if not (project / RENDERER_SCRIPT_REL).is_file(): + problems.append( + Finding( + "skills.dev-renderer", + "problem", + f"{tree}/{resolved}/SKILL.md renders via {RENDERER_SCRIPT_MARKER} " + f"but {RENDERER_SCRIPT_REL} is missing — the session would HALT " + f"without writing a spec; reinstall the BMad Method (bmm) module", + {"tree": tree, "skill": resolved, "script": RENDERER_SCRIPT_REL}, + ) + ) for skill, markers in REVIEW_HUNTER_SKILLS.items(): skill_dir = project / tree / skill if not (skill_dir / "SKILL.md").is_file(): @@ -366,76 +416,44 @@ 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. - All three conditions are things a run can survive but an operator wants named: - - - ``skills.dev-renderer``: SKILL.md is a renderer stub (BMAD-METHOD #2601) but - the project has no ``_bmad/scripts/render_skill.py``. The session would HALT - before writing anything. Only the script's presence is probed — not uv on - PATH — so this stays a warning rather than a FAIL. - - ``skills.dev-renderer-config``: a stub resolved somewhere but the project has - no ``_bmad/config.toml``, the renderer's one required config layer. Same - result-less HALT, one file further in — and no other check sees it, because - the failure is a ``ConfigError`` inside a subprocess the orchestrator only - observes as a Stop with no artifacts. Emitted once per project (the central - config is project-global, not per tree) and gated on a stub having resolved, - which keeps the check era-agnostic: a pre-#2601 inline SKILL.md never reads - the file, so its absence is not a finding to make about that project. + 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). + 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. - Script-missing and config-missing are deliberately NOT suppressed against each - other: different files, different remediations, and a wholly-absent ``_bmad/`` - legitimately earns both lines. + 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 = False - resolved_stub = False - for tree in dict.fromkeys(trees): - resolved = resolve_dev_primitive(project, tree) - if resolved is None: - continue - resolved_new = resolved_new or resolved == DEV_PRIMITIVE_NEW - try: - skill_md = (project / tree / resolved / "SKILL.md").read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - # An unreadable/binary SKILL.md cannot be shown to be a renderer stub; - # missing_base_skills has already spoken about this tree's health. - continue - if RENDERER_SCRIPT_MARKER not in skill_md: - continue - resolved_stub = True - if not (project / RENDERER_SCRIPT_REL).is_file(): - findings.append( - Finding( - "skills.dev-renderer", - "warning", - f"{tree}/{resolved}/SKILL.md renders via {RENDERER_SCRIPT_MARKER} but " - f"{RENDERER_SCRIPT_REL} is missing — the session would HALT without " - f"writing a spec; reinstall the BMad Method (bmm) module", - {"tree": tree, "skill": resolved, "script": RENDERER_SCRIPT_REL}, - ) - ) - if resolved_stub and not (project / CENTRAL_CONFIG_REL).is_file(): - findings.append( - Finding( - "skills.dev-renderer-config", - "warning", - 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}, - ) - ) + 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}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 872a08c2..a76eb5a8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1591,6 +1591,65 @@ def test_run_aborts_when_only_the_dev_shim_is_installed(project, monkeypatch, ca assert "bmad-build-auto" in err and "HALT" in err +def _install_renderer_stub_project(project, monkeypatch, *, script: bool, config: bool): + """A project whose resolved primitive is a #2601 renderer stub in every tree. + `script`/`config` choose which of the two renderer prerequisites exist, so each + caller isolates one absence. Everything is laid down before the commit — the + preflight runs behind a clean-worktree gate.""" + from conftest import 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\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, 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_central_config_is_missing(project, monkeypatch, capsys): + """One file further in: the script is there 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, 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_proceeds_once_the_renderer_files_are_present(project, monkeypatch, capsys): + """The clearing leg. Without it, ablating either `is_file()` predicate away + leaves both aborts above passing — the run would refuse a healthy stub project + and no test would notice.""" + _install_renderer_stub_project(project, monkeypatch, script=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): import bmad_loop.tui.app as tui_app @@ -3484,11 +3543,12 @@ def test_validate_reports_the_shim_as_a_problem(project, capsys, monkeypatch): assert "skills.base-shim" in _render_findings(doc) # new detail shape draws -def test_validate_warns_on_a_renderer_stub_and_an_orphaned_customize_file( +def test_validate_fails_on_a_renderer_stub_and_warns_on_an_orphaned_customize_file( project, capsys, monkeypatch ): - """All three dev-primitive warnings are advisory: they ride along in the - document and must NOT flip the verdict on their own. + """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 @@ -3504,11 +3564,11 @@ def test_validate_warns_on_a_renderer_stub_and_an_orphaned_customize_file( 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 # warnings do not fail the run + 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"] == "warning" - assert found["skills.dev-renderer-config"]["severity"] == "warning" # no _bmad/config.toml + 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 @@ -3516,6 +3576,33 @@ def test_validate_warns_on_a_renderer_stub_and_an_orphaned_customize_file( 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) @@ -4054,6 +4141,29 @@ def test_sweep_dry_run_warns_when_preflight_would_abort(project, capsys): 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 diff --git a/tests/test_install.py b/tests/test_install.py index 2a476efb..5d382af7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -821,55 +821,60 @@ def test_resolution_is_per_tree(tmp_path): assert missing_base_skills(tmp_path, [claude, codex]) == [] -def test_dev_primitive_warnings_flag_a_renderer_stub_without_its_script(tmp_path): +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 — but it - is a warning, not a FAIL: only the script's presence is probed, never uv.""" + 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, dev_primitive_warnings + 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 warns, script or no script + # an inline (non-stub) SKILL.md never fires, script or no script install_build_auto_skill(tmp_path, tree) - assert dev_primitive_warnings(tmp_path, [tree]) == [] + assert missing_base_skills(tmp_path, [tree]) == [] install_build_auto_skill(tmp_path, tree, renderer_stub=True) - warnings = dev_primitive_warnings(tmp_path, [tree]) - assert [f.check for f in warnings] == ["skills.dev-renderer"] - assert warnings[0].severity == "warning" - assert warnings[0].check in VALIDATE_CHECKS - assert RENDERER_SCRIPT_REL in warnings[0].message + 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 dev_primitive_warnings(tmp_path, [tree]) == [] + assert missing_base_skills(tmp_path, [tree]) == [] -def test_dev_primitive_warnings_config_warning_when_central_toml_absent(tmp_path): +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 warning guards, one file further in, and + 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, dev_primitive_warnings + 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 dev_primitive_warnings(tmp_path, [tree])] == [ + assert [f.check for f in missing_base_skills(tmp_path, [tree])] == [ "skills.dev-renderer", "skills.dev-renderer-config", ] @@ -877,52 +882,93 @@ def test_dev_primitive_warnings_config_warning_when_central_toml_absent(tmp_path script = tmp_path / RENDERER_SCRIPT_REL script.parent.mkdir(parents=True, exist_ok=True) script.write_text("# renderer\n", encoding="utf-8") - warnings = dev_primitive_warnings(tmp_path, [tree]) - assert [f.check for f in warnings] == ["skills.dev-renderer-config"] - assert warnings[0].severity == "warning" - assert warnings[0].check in VALIDATE_CHECKS - assert CENTRAL_CONFIG_REL in warnings[0].message - assert warnings[0].detail["config"] == CENTRAL_CONFIG_REL + 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) + # …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 dev_primitive_warnings(tmp_path, [tree]) == [] + assert missing_base_skills(tmp_path, [tree]) == [] -def test_dev_primitive_warnings_config_silent_for_pre_render_skill(tmp_path): +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, dev_primitive_warnings + 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 dev_primitive_warnings(tmp_path, [claude, codex]) == [] + assert missing_base_skills(tmp_path, [claude, codex]) == [] -def test_dev_primitive_warnings_config_emitted_once_across_two_trees(tmp_path): +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, dev_primitive_warnings + 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) - warnings = dev_primitive_warnings(tmp_path, [claude, codex]) - assert [f.check for f in warnings] == ["skills.dev-renderer-config"] + 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 both lines. Different files, different + remediations (ablation: put the renderer probe in an elif).""" + 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-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"] def test_dev_primitive_warnings_flag_an_orphaned_legacy_customize_file(tmp_path): @@ -959,12 +1005,14 @@ def test_dev_primitive_warnings_flag_an_orphaned_legacy_customize_file(tmp_path) def test_dev_primitive_warnings_are_silent_when_nothing_resolves(tmp_path): - """missing_base_skills owns the unresolvable story; the warnings must not pile + """missing_base_skills owns the unresolvable story; the warning must not pile advisory noise on top of a hard preflight failure. - Both warning sites are armed here — an orphaned customize file AND a shim whose - SKILL.md is a renderer stub — so the silence is the resolution guard's doing and - not merely the absence of anything to say.""" + 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 From f325920cba92749e697a71b78ad10e3f4a5f93b1 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 21:57:09 -0700 Subject: [PATCH 13/41] fix(worktree): pause the run when the _bmad/scripts seed comes up short (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_bmad_scripts_seed_incomplete` (9ca3ee1) already detects a worktree whose renderer support did not make it across — the usual cause is a symlinked `_bmad/` pointing outside the repo, which worktree seeding cannot follow — but it reported through the informational `worktree-seed-skipped` channel and `_run_isolated` fell straight through to dispatch. Every story then drove a renderer stub with no `render_skill.py`, Stopped having written nothing, and the run reported `0 done`: green through a guaranteed failure. The engine now escalates on that one entry. It is the env-fault shape `VerifyOutcome.env_fault` names — no repair session can fix it and no other story escapes it — so it pauses rather than defers, which would walk the whole backlog into the same wall. The gate sits after the journal line and before the workspace swap, leaving the half-seeded worktree mounted for inspection, and sets `Phase.ESCALATED` directly because PENDING has no legal move there (the sibling worktree-open-failed branch sets DEFERRED the same way). `provision_worktree` keeps its `list[str]` return contract and gains no raise path; the sentinel it appends is now the exported `install.BMAD_SCRIPTS_SEED_REL`, so neither side matches a magic string. Reported by Codex on #406. --- src/bmad_loop/engine.py | 32 ++++++++++++- src/bmad_loop/install.py | 18 ++++++-- tests/test_engine_worktree.py | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 9b30581e..fd403a7a 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -30,7 +30,7 @@ decide_review_session, preference_escalations, ) -from .install import dev_primitive_or_default, provision_worktree +from .install import BMAD_SCRIPTS_SEED_REL, dev_primitive_or_default, provision_worktree from .journal import Journal, save_state from .model import ( PAUSE_EPIC_BOUNDARY, @@ -627,6 +627,36 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) + if BMAD_SCRIPTS_SEED_REL in skipped_seeds: + # ...with exactly one exception, and it is not informational: the + # worktree's `_bmad/scripts/` came up SHORT of the repo's. The renderer + # stub would run without `render_skill.py`/`config_utils.py` 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. + # + # 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"{BMAD_SCRIPTS_SEED_REL} is incomplete in the worktree — the renderer " + f"would HALT without writing a spec. The usual cause is a symlinked " + f"_bmad/ pointing 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.journal.append( "worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path) ) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 9f6e7574..ca1284de 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -115,6 +115,13 @@ # reason. Project-global, not per tree. CENTRAL_CONFIG_REL = f"{BMAD_DIR}/config.toml" +# The one `provision_worktree` skipped-seed entry that is NOT informational: it says +# the worktree's renderer support came up SHORT, not that a seed was a no-op. Shared +# with the engine, which escalates on 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. +BMAD_SCRIPTS_SEED_REL = f"{BMAD_DIR}/scripts" + # 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 @@ -790,9 +797,12 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: ``HALT:`` line is lost. Reported through :func:`provision_worktree`'s existing skipped-seed return - channel (the caller journals ``worktree-seed-skipped``) rather than raising: - an incomplete seed is a degraded provision, not a failed one, and that channel - needs no engine or journal-schema change. + channel, as the :data:`BMAD_SCRIPTS_SEED_REL` entry, rather than raising — + provisioning has no failure path of its own and inventing one here would put a + policy decision inside a quiet, TUI-hosted helper. The *engine* reads that + entry back and escalates: 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 (repo_root / RENDERER_SCRIPT_REL).is_file(): return False @@ -965,7 +975,7 @@ def provision_worktree( # 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)] if _bmad_scripts_seed_incomplete(worktree, repo_root): - skipped.append(f"{BMAD_DIR}/scripts") + skipped.append(BMAD_SCRIPTS_SEED_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). diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 5bd67599..a591c7f5 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -8,8 +8,11 @@ from __future__ import annotations +import os import shutil +from pathlib import Path +import pytest from conftest import ( _OK, _exists_run, @@ -722,6 +725,90 @@ def dev_and_probe(spec): 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.""" + 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, _ = make_engine(project, [never, never]) + 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 + kinds = [e["kind"] for e in Journal(engine.run_dir).entries()] + assert "story-escalated" in kinds + # 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 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 + + 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 From 2c553df97981c858b8b8ac77ae9cbc280ddaa0d8 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 21:57:21 -0700 Subject: [PATCH 14/41] fix(engine): make the harvest revert survive a crash replay (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20d3dc0 made the RETRY revert unconditional by snapshotting the ledger before the harvest and restoring it around the rollback. The snapshot is a plain local, and `_dev_phase` re-enters its loop from the top on a replay: lines that re-arm `ledger_before = None` / `ledger_snapshotted = False` run unconditionally, unlike the baseline capture above them. So a host death between the harvest and the rollback left the next process re-snapshotting the dead attempt's POST-harvest text and writing it back. On a tracked ledger that is worse than doing nothing: `reset --hard <baseline_commit>` had already reverted the harvest edit, and the restore put it back. The snapshot-restore actively re-created what the reset undid. Fixed by recovering the attempt baseline rather than persisting the snapshot — persisting the ledger *text* would grow `state.json` without bound. A replayed attempt takes no snapshot (there is nothing honest to restore) and the `finally` calls `_drop_ledger_created_since_baseline`, which reads the already-persisted `baseline_untracked`: listed there means untracked and present at the baseline, unrecoverable, hands off; untracked now and absent from it means this attempt created the file, unlink; neither means tracked, and the reset has already spoken. The untracked probe is an observation and degrades on `GitError` — it runs in a `finally` that is usually propagating `RunPaused` out of `_pause_for_manual_recovery`, and must never replace that pause with a git failure. One asymmetry is documented at the helper: under the default `scm.rollback_on_failure = off` no reset runs, so a tracked ledger keeps its harvest edit until the operator's own reset. Reported by Codex on #406, which understates it — see the tracked leg. --- src/bmad_loop/engine.py | 79 ++++++++++++++++++++++- tests/test_engine.py | 137 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index fd403a7a..ee209741 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1764,6 +1764,7 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # snapshot was taken at all" (nothing to restore, hands off). ledger_before: str | None = None ledger_snapshotted = False + 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 @@ -1816,8 +1817,16 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # artifact gate that can still send this attempt back. Snapshot the # ledger here so the rollback half is enforced explicitly, at the # RETRY branch below. - ledger_before = self._ledger_text() - ledger_snapshotted = True + # + # NOT on a replayed attempt: the host died somewhere between the + # dead process's harvest and its rollback, so what is on disk now is + # already POST-harvest and snapshotting it would restore the very + # edit the rollback is meant to undo — worse than not restoring, on + # a tracked ledger, because the reset alone would have reverted it. + # The `finally` recovers the attempt baseline instead. + if not replayed: + ledger_before = self._ledger_text() + ledger_snapshotted = True 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. @@ -1880,8 +1889,17 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # untracked ledger either. Lossless because the harvest # never mutates the spec's `deferred:` frontmatter — the # next attempt re-harvests from it. + # + # Two paths because the snapshot is a plain local and a host + # death loses it: a live attempt restores its own snapshot; a + # REPLAYED one recovers the attempt baseline from persisted + # state instead. Restoring a freshly-read snapshot there would + # write the dead attempt's harvest back — see + # _drop_ledger_created_since_baseline. if ledger_snapshotted: self._restore_ledger(ledger_before) + elif replayed: + self._drop_ledger_created_since_baseline(task) continue if decision.action == Action.DEFER: self._record_dev_spec(task, result.result_json) @@ -2543,6 +2561,11 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> would survive (#405). `_dev_phase` therefore snapshots the ledger immediately before this call and restores it around `_rollback_or_pause` — that restore, not the reset, is what makes the revert unconditional. + On a REPLAYED attempt that snapshot no longer exists (it is a local, and the + host died holding it), so the revert falls back to recovering the attempt + baseline from persisted state — see + `_drop_ledger_created_since_baseline`, which also documents the one case + that stays unrecoverable and is therefore left alone. 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, @@ -3289,6 +3312,58 @@ def _restore_ledger(self, snapshot: str | None) -> None: ledger.parent.mkdir(parents=True, exist_ok=True) ledger.write_text(snapshot, encoding="utf-8") + def _drop_ledger_created_since_baseline(self, task: StoryTask) -> None: + """Revert a harvest whose in-process snapshot died with the host. + + A crash between `_harvest_spec_deferrals` and the non-fixable RETRY rollback + leaves the attempt resumable: `_resumable_session` replays the recorded + result and `_dev_phase` re-enters its loop from the top. The pre-harvest + snapshot is a plain local, so it is gone — but the attempt *baseline* is + persisted (`baseline_commit`, `baseline_untracked`) and is deliberately not + re-captured on a replay, which is enough to recover the ledger's state at the + start of this attempt without persisting the ledger text itself: + + - listed in ``baseline_untracked`` — untracked and PRESENT at the baseline. + Its pre-harvest content is genuinely unrecoverable, so hands off rather + than guess; the harvest's own fingerprint dedup keeps a re-harvest quiet. + - untracked NOW and absent from ``baseline_untracked`` — this attempt created + it (the first-ever harvest does exactly this), so unlink. + - neither — tracked, and `_rollback_or_pause`'s `reset --hard` has already + reverted the harvest edit. Touching it would put the edit BACK. + + The last case is why the replayed path cannot simply reuse `_restore_ledger` + with a freshly-read snapshot: on a tracked ledger that would re-create the + very edit the reset just undid. + + One asymmetry, deliberate: under the default ``scm.rollback_on_failure = off`` + no reset runs at all, so a *tracked* ledger keeps its harvest edit here where + the non-crash path would have restored pre-harvest text. The run is paused and + the operator's own `reset --hard` reverts it; inventing a checkout of the + baseline blob inside a `finally` that is already unwinding a pause is the + worse trade. + + The untracked probe is an OBSERVATION and degrades: it runs inside a + ``finally`` that is usually propagating `RunPaused` out of + `_pause_for_manual_recovery`, and a `GitError` raised here would replace that + pause with a git failure. The unlink itself still raises, like every other + ledger write.""" + ledger = self.workspace.paths.deferred_work + try: + rel = ledger.relative_to(self.workspace.root).as_posix() + except ValueError: + return # ledger configured outside the workspace: not ours to reason about + if rel in (task.baseline_untracked or ()): + return + try: + untracked = verify.untracked_files(self.workspace.root) + except verify.GitError as e: + self.journal.append( + "ledger-baseline-probe-failed", story_key=task.story_key, error=str(e) + ) + return + if rel in untracked: + ledger.unlink(missing_ok=True) + 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/tests/test_engine.py b/tests/test_engine.py index c14a2144..ee1d40ac 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5842,6 +5842,143 @@ def test_harvest_tracked_ledger_still_reverted_on_retry(project): assert _ledger_entries(project) == [] +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): + """The in-process snapshot is a local and dies with the host. 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. + + `task.baseline_untracked` survives the crash and is deliberately not + re-captured on a replay, so it still answers the question that matters: this + ledger was absent at the attempt baseline, is untracked now, therefore the dead + attempt created it — unlink.""" + 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): + """The leg the finding understates: 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 recovery therefore keeps its hands off a tracked ledger, and the + pre-existing committed entry has to survive 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_never_unlinks_a_ledger_untracked_at_the_baseline(project): + """The third state, and the one that must NOT be reverted: a ledger the + operator already had on disk but never committed. It is untracked at the + rollback exactly like a harvest-created one, so the `baseline_untracked` shield + is the ONLY thing separating them — without it the recovery deletes real + operator content that no reset would have touched and no commit can restore. + + The harvested entry riding along is the acknowledged cost: its pre-harvest text + is genuinely unrecoverable here, so the revert declines rather than guesses, and + the harvest's fingerprint dedup keeps the re-harvest quiet.""" + 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", + ) + 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") + ledger_rel = str(project.deferred_work.relative_to(project.project)) + 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 + + 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 + assert HARVEST_A["summary"] in titles # documented, not accidental + + def test_harvest_retry_reharvests_after_the_rollback(project): """The revert must lose nothing, and must not reach onto the fixable branch. From 0b610831a8d0a24a8982abf9446f1cec08f051ed Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 22:05:44 -0700 Subject: [PATCH 15/41] docs(changelog): reclassify the renderer checks and the two crash legs (#405) The renderer checks moved from Added to Fixed and from warnings to preflight problems; the worktree bullet's incomplete `_bmad/scripts` seed now pauses the run rather than reporting through a journal event; the harvest-revert bullet gains the crash-replay leg. Amends the untagged 0.9.1 section in place. --- CHANGELOG.md | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ca9656..da30e6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,6 @@ Compatibility hotfix for the BMad Method's `bmad-dev-auto` → `bmad-build-auto` that rode the same window. Both skill eras are supported; nothing in `policy.toml` needs editing. -### Added - -- **Three validate warnings for post-rename installs (#405).** `skills.dev-renderer` fires when - the resolved `SKILL.md` is the new renderer stub (BMAD-METHOD#2601) but - `_bmad/scripts/render_skill.py` is missing — that session would HALT without writing a spec. - `skills.dev-renderer-config` fires when a stub resolved but `_bmad/config.toml` — the - renderer's one required config layer — is absent: the same result-less HALT, one file further - in, and no other check sees it. It is emitted once per project and only when a stub actually - resolved, so a pre-renderer install stays silent. `skills.customize-legacy` 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. - ### Fixed - **The dev primitive is now resolved on disk, so the upstream rename no longer breaks a @@ -48,6 +36,20 @@ editing. 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". +- **A renderer stub that cannot compose a prompt fails the preflight (#405).** Two new checks + block `validate`/`run`/`sweep`/`resume`: `skills.dev-renderer`, when the resolved `SKILL.md` + is the new renderer stub (BMAD-METHOD#2601) but `_bmad/scripts/render_skill.py` is missing, + and `skills.dev-renderer-config`, when a stub resolved but `_bmad/config.toml` — the + renderer's one required config layer — is absent. Either way the stub's `uv run` exits `HALT:` + and the session Stops having written no spec; both files are project-global, so every story + after it does the same. They block rather than warn because only a green is untrustworthy here + (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 both under its "NOT runnable as-is" banner. A third 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 @@ -55,11 +57,13 @@ editing. `_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 — is - reported through the existing `worktree-seed-skipped` journal event instead of passing as - success. `_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"`. + short — the realistic trigger is a symlinked `_bmad/`, how a shared BMad install is wired — + pauses the run. 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/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"`. - **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 @@ -84,7 +88,11 @@ editing. 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. A defer still keeps its + frontmatter is never mutated and the next attempt re-harvests from it. That snapshot is + in-process, so a host death between the harvest and the rollback loses it; the replayed + attempt recovers the ledger's pre-harvest state from the persisted baseline instead — + deleting the file when the attempt created it, and leaving a tracked one to the reset, which + would otherwise be handed the dead attempt's harvest straight back. A defer still keeps its harvested entries: `_stash_deferred_artifacts` has already moved the spec out of the artifacts dir, so there the ledger entry is the finding's only surviving record. From 25997c46910debfd73d63e95c2002f182b464e3f Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 22:12:52 -0700 Subject: [PATCH 16/41] fix(test): compare the ledger rel in posix form, like the code under test (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline`'s precondition built its rel with `str(Path.relative_to(...))`, which is native-separator. `verify.untracked_files` returns git's posix rels, so on Windows the assert could never hold and the test self-failed before reaching what it pins — both Windows CI jobs, invisible on POSIX where the two spellings coincide. Production is unaffected: `_drop_ledger_created_since_baseline` already uses `.as_posix()`, and `task.baseline_untracked` is filled from the same `untracked_files` call, so both sides of the shield were posix-vs-posix. --- tests/test_engine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index ee1d40ac..e2f7a935 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5959,7 +5959,10 @@ def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): 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") - ledger_rel = str(project.deferred_work.relative_to(project.project)) + # 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 — + # exactly what `_drop_ledger_created_since_baseline` uses `.as_posix()` for. + 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)) From 15f0a80be400b0eb8307a0be82082f316bd121a5 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 22:14:39 -0700 Subject: [PATCH 17/41] docs(worktree): state the real reason the seed report is a return, not a raise (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_bmad_scripts_seed_incomplete`'s docstring said raising would put a policy decision "inside a quiet, TUI-hosted helper". The TUI never constructs an Engine — it observes state.json and the journal — so `provision_worktree` is not TUI-hosted and that clause was doing no work. The load-bearing reasons are that provisioning has no failure vocabulary at all (every containment violation is a bare `continue`) and that the decision being made is escalate-vs-defer plus notify and pause, which belongs to the caller owning the state machine. --- src/bmad_loop/install.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index ca1284de..ebd58255 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -798,11 +798,12 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: 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 path of its own and inventing one here would put a - policy decision inside a quiet, TUI-hosted helper. The *engine* reads that - entry back and escalates: 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. + provisioning has no failure vocabulary of its own (every containment violation + is a bare ``continue``), and the decision this drives is escalate-vs-defer plus + notify and pause, which belongs to the caller that owns the state machine and + the journal. The *engine* reads the entry back and escalates: 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 (repo_root / RENDERER_SCRIPT_REL).is_file(): return False From 19d21d5046a65473e25d06055334c6f9324a7949 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 23:34:42 -0700 Subject: [PATCH 18/41] fix(worktree): gate the _bmad/scripts seed escalation on a renderer stub (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_bmad_scripts_seed_incomplete` can only see the REPO: it fires on any project carrying a renderer-era `_bmad/scripts/` the seed cannot follow — a shared BMad install wired as a symlink is the canonical shape. `_run_isolated` escalated on bare membership of that sentinel, so a project whose resolved dev primitive is a pre-#2601 INLINE `SKILL.md` had its whole backlog halted on story 1 for a renderer it never invokes. Preflight cannot pre-empt it: `skills.dev-renderer` is itself stub-gated, so an inline install passes clean. New public `install.renderer_stub_resolved(project, trees)` — the `resolved_stub` flag `missing_base_skills` folds into its renderer findings, lifted out for the caller that needs the answer without the findings — becomes a conjunct on the gate. Engine-side rather than provision-side because provisioning is handed `repo_root` while the primitive resolves against `paths.project`, and those genuinely diverge under a configured `repo_root`. ANY over the dev+review trees, deduped: resolution is per tree and either session kind needing the renderer is enough. `provision_worktree` stays a pure reporter; its output is byte-identical. The escalation reason now leads with the evidence the gate can actually back. Tests: `test_incomplete_bmad_scripts_seed_pauses_before_dispatch`'s fixture had no skill tree at all, so it asserted a pause with no renderer on disk — armed with a real stub + `attach_profile`. Two new legs, because deleting the conjunct passed all 145 worktree+install tests: `test_a_short_scripts_seed_does_not_pause_an_inline_primitive` (the era discriminator — byte-identical environment, inline primitive, whole backlog runs) and `test_a_stub_in_the_review_tree_alone_still_pauses` (the only ablation that catches a dev-role-only implementation). --- src/bmad_loop/engine.py | 45 ++++++++--- src/bmad_loop/install.py | 64 +++++++++++++-- tests/test_engine_worktree.py | 144 +++++++++++++++++++++++++++++++++- tests/test_install.py | 36 +++++++++ 4 files changed, 267 insertions(+), 22 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index ee209741..119ea39e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -30,7 +30,13 @@ decide_review_session, preference_escalations, ) -from .install import BMAD_SCRIPTS_SEED_REL, dev_primitive_or_default, provision_worktree +from .install import ( + BMAD_SCRIPTS_SEED_REL, + RENDERER_SCRIPT_MARKER, + dev_primitive_or_default, + provision_worktree, + renderer_stub_resolved, +) from .journal import Journal, save_state from .model import ( PAUSE_EPIC_BOUNDARY, @@ -627,15 +633,31 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) - if BMAD_SCRIPTS_SEED_REL in skipped_seeds: - # ...with exactly one exception, and it is not informational: the - # worktree's `_bmad/scripts/` came up SHORT of the repo's. The renderer - # stub would run without `render_skill.py`/`config_utils.py` 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. + if BMAD_SCRIPTS_SEED_REL in skipped_seeds 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 `_bmad/scripts/` came up SHORT of the + # repo's. A renderer stub would then run without + # `render_skill.py`/`config_utils.py` 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. + # + # The renderer conjunct is what keeps this era-agnostic, and it is not + # optional: `_bmad_scripts_seed_incomplete` can only see the REPO (it fires + # on any repo carrying a renderer-era `_bmad/scripts/` 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 `_bmad/scripts/` at all, 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 @@ -643,7 +665,8 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> # 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"{BMAD_SCRIPTS_SEED_REL} is incomplete in the worktree — the renderer " + f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but " + f"{BMAD_SCRIPTS_SEED_REL} is incomplete in the worktree — the session " f"would HALT without writing a spec. The usual cause is a symlinked " f"_bmad/ pointing outside the repo, which worktree seeding cannot follow" ) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index ebd58255..85a01356 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -115,11 +115,15 @@ # reason. Project-global, not per tree. CENTRAL_CONFIG_REL = f"{BMAD_DIR}/config.toml" -# The one `provision_worktree` skipped-seed entry that is NOT informational: it says -# the worktree's renderer support came up SHORT, not that a seed was a no-op. Shared -# with the engine, which escalates on 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. +# 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" # Top-level _bmad/ entries never seeded into a worktree. render/ is the renderer's @@ -228,6 +232,36 @@ def _is_renderer_stub(skill_dir: Path) -> bool: return RENDERER_SCRIPT_MARKER in skill_md +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. @@ -338,6 +372,9 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: lines beside a truncated skill. """ 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): resolved = resolve_dev_primitive(project, tree) @@ -801,8 +838,16 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: provisioning has no failure vocabulary of its own (every containment violation is a bare ``continue``), and the decision this drives is escalate-vs-defer plus notify and pause, which belongs to the caller that owns the state machine and - the journal. The *engine* reads the entry back and escalates: this is not a - degraded provision the run can carry, it is an environment fault identical for + 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 (repo_root / RENDERER_SCRIPT_REL).is_file(): @@ -913,7 +958,10 @@ def provision_worktree( no-ops. The caller journals them: a user-authored `worktree_seed` entry that silently copies nothing reads as applied configuration and is not. The same channel also reports an INCOMPLETE `_bmad/scripts/` seed (see - _bmad_scripts_seed_incomplete), which is likewise silent otherwise. + _bmad_scripts_seed_incomplete), which is likewise silent otherwise. Whether THAT + one 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 + BMAD_SCRIPTS_SEED_REL). """ if not profiles and not seed_files and not seed_globs and not (repo_root / BMAD_DIR).is_dir(): return [] diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index a591c7f5..10cbf9ec 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -29,6 +29,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, 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 @@ -734,7 +735,15 @@ def test_incomplete_bmad_scripts_seed_pauses_before_dispatch(project, tmp_path): 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.""" + 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() @@ -753,7 +762,8 @@ def never(spec): dispatched.append(spec.cwd.name) raise AssertionError("dispatched into a worktree with no renderer") - engine, _ = make_engine(project, [never, never]) + 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 @@ -764,8 +774,12 @@ def never(spec): # 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 - kinds = [e["kind"] for e in Journal(engine.run_dir).entries()] + 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"] # 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 @@ -775,6 +789,130 @@ def never(spec): assert not (Path(task.worktree_path) / "_bmad" / "scripts" / "render_skill.py").exists() +@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 diff --git a/tests/test_install.py b/tests/test_install.py index 5d382af7..0acf2e0a 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -914,6 +914,42 @@ def test_renderer_checks_silent_for_pre_render_skill(tmp_path): 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 From e370528df68192fed3900b863c07cc2e12909f0a Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 23:34:58 -0700 Subject: [PATCH 19/41] fix(engine): classify the crash-replay ledger by filesystem, not by git (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_drop_ledger_created_since_baseline` classified the deferred-work ledger from `task.baseline_untracked` plus `verify.untracked_files`, and both come from `git ls-files --others --exclude-standard`, which excludes IGNORED files. A gitignored ledger is absent from both, so it fell into the third bucket ("neither ⇒ tracked ⇒ `reset --hard` already reverted it") — false: the reset skips ignored paths, `safe_rollback` runs no `git clean` at all, and the artifacts dir is `keep`-shielded besides. The dead attempt's harvested finding survived the rollback and was later swept or committed. Not hypothetical: this repo gitignores `_bmad-output/`, where the default ledger lives, while `bmad-loop init` does not write that rule — so both layouts exist in the wild. Widening only the NOW probe would have been worse than the bug: an ignored ledger the operator already had is also absent from `baseline_untracked`, so it would classify as attempt-created and be unlinked. The baseline record and the now-probe have to move together. - `StoryTask.baseline_ledger_present: bool | None`, captured beside `baseline_commit`/`baseline_untracked` as a plain `Path.is_file()`. `from_dict` uses `d.get(k) is not None`, NOT the neighbours' `bool(d.get(k, False))`: a pre-upgrade task must rehydrate to None, since False is the only value that authorizes a delete. - New `verify.path_tracked` (`git ls-files -- <rel>`; only the output's emptiness is read). Not `--error-unmatch`, which conflates "untracked" with "git blew up"; not `check-ignore`, which answers whether a RULE matches — a `git add -f`'d ledger must read tracked. - Both ledger methods now share `_ledger_is_gits_to_restore`, which degrades to True: uncertainty must never authorize a delete. `_restore_ledger(None)` needed it too and needs NO crash to reach — on a tracked ledger the operator deleted from the worktree it was unlinking a file the reset had just restored. Verified end to end in a sandbox (crash plugin bound to post_dev_verify → resume → rollback): pre-fix, the run reports `1 done` / `run-complete` with the dead attempt's entry still in the ledger. --- src/bmad_loop/engine.py | 141 +++++++++++++++++------- src/bmad_loop/model.py | 27 +++++ src/bmad_loop/verify.py | 27 +++++ tests/test_engine.py | 231 ++++++++++++++++++++++++++++++++++++++++ tests/test_model.py | 19 ++++ tests/test_verify.py | 31 ++++++ 6 files changed, 439 insertions(+), 37 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 119ea39e..1f02b947 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1777,6 +1777,16 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # would shift the rollback/squash reference onto the completed # session's own tree. task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root)) + # ...and whether the deferred-work ledger itself was on disk. Deliberately + # NOT a git question: the ledger is commonly gitignored, and both git-side + # signals above exclude ignored paths, so for exactly that layout a + # gitignored ledger reads as BOTH "absent at the baseline" and "not created + # by this attempt" — and the crash-replay revert below then leaves the dead + # attempt's finding behind. Widening the NOW probe alone would be worse: an + # ignored ledger the operator already had would classify as attempt-created + # and get deleted. This bit is the symmetric half, and it is captured with + # the other two so all three always describe the same moment. + task.baseline_ledger_present = self.workspace.paths.deferred_work.is_file() feedback: Path | None = None while True: # Pre-harvest ledger snapshot for the non-fixable-RETRY revert below, @@ -1920,7 +1930,7 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # write the dead attempt's harvest back — see # _drop_ledger_created_since_baseline. if ledger_snapshotted: - self._restore_ledger(ledger_before) + self._restore_ledger(task, ledger_before) elif replayed: self._drop_ledger_created_since_baseline(task) continue @@ -3308,7 +3318,38 @@ def _ledger_text(self) -> str | None: ledger = self.workspace.paths.deferred_work return ledger.read_text(encoding="utf-8") if ledger.is_file() else None - def _restore_ledger(self, snapshot: str | None) -> 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. @@ -3319,6 +3360,15 @@ def _restore_ledger(self, snapshot: str | None) -> None: `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. @@ -3330,7 +3380,8 @@ def _restore_ledger(self, snapshot: str | None) -> None: if current == snapshot: return if snapshot is None: - ledger.unlink(missing_ok=True) + 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") @@ -3341,22 +3392,40 @@ def _drop_ledger_created_since_baseline(self, task: StoryTask) -> None: A crash between `_harvest_spec_deferrals` and the non-fixable RETRY rollback leaves the attempt resumable: `_resumable_session` replays the recorded result and `_dev_phase` re-enters its loop from the top. The pre-harvest - snapshot is a plain local, so it is gone — but the attempt *baseline* is - persisted (`baseline_commit`, `baseline_untracked`) and is deliberately not - re-captured on a replay, which is enough to recover the ledger's state at the - start of this attempt without persisting the ledger text itself: - - - listed in ``baseline_untracked`` — untracked and PRESENT at the baseline. - Its pre-harvest content is genuinely unrecoverable, so hands off rather - than guess; the harvest's own fingerprint dedup keeps a re-harvest quiet. - - untracked NOW and absent from ``baseline_untracked`` — this attempt created + snapshot is a plain local, so it is gone — but ``baseline_ledger_present`` is + persisted with the rest of the attempt baseline and deliberately not + re-captured on a replay, so it still answers the only question that matters: + + - PRESENT at the baseline — the harvest appended to a ledger that was already + there. Its pre-harvest content is genuinely unrecoverable, so hands off + rather than guess; the harvest's own fingerprint dedup keeps a re-harvest + quiet. Covers a tracked, an untracked and an ignored ledger alike, which is + the point: only the filesystem can speak for all three. + - ABSENT at the baseline, and git does not own the path — this attempt created it (the first-ever harvest does exactly this), so unlink. - - neither — tracked, and `_rollback_or_pause`'s `reset --hard` has already - reverted the harvest edit. Touching it would put the edit BACK. - - The last case is why the replayed path cannot simply reuse `_restore_ledger` - with a freshly-read snapshot: on a tracked ledger that would re-create the - very edit the reset just undid. + - ABSENT at the baseline, but TRACKED — the operator had deleted a committed + ledger without committing that; `_rollback_or_pause`'s `reset --hard` has + restored it and deleting it here would lose a file no rollback asked to lose. + See `_ledger_is_gits_to_restore`. + - ``None`` — a task persisted before the bit existed. Released 0.9.1 carried + the harvest with no revert at all, so doing nothing is exactly what that run + would have done; refusing to guess also matches `safe_rollback`, which + removes nothing when `baseline_untracked` is None. Journalled, not silent. + + Why a persisted filesystem bit rather than git: `baseline_untracked` and + `verify.untracked_files` both come from `git ls-files --others + --exclude-standard`, so a GITIGNORED ledger is missing from both and git keeps + no record of an ignored file's prior existence at all. Classifying on those two + put every ignored ledger in the "tracked ⇒ the reset already reverted it" + bucket, where nothing reverts it — the reset skips ignored paths, this module + never runs `git clean`, and `_safe_reset`'s `keep` shields the artifacts dir + besides. Making only the NOW probe ignore-aware would have been worse: an + ignored ledger the operator already had would have classified as + attempt-created and been deleted. + + The tracked case is also why the replayed path cannot simply reuse + `_restore_ledger` with a freshly-read snapshot: on a tracked ledger that would + re-create the very edit the reset just undid. One asymmetry, deliberate: under the default ``scm.rollback_on_failure = off`` no reset runs at all, so a *tracked* ledger keeps its harvest edit here where @@ -3365,27 +3434,25 @@ def _drop_ledger_created_since_baseline(self, task: StoryTask) -> None: baseline blob inside a `finally` that is already unwinding a pause is the worse trade. - The untracked probe is an OBSERVATION and degrades: it runs inside a - ``finally`` that is usually propagating `RunPaused` out of - `_pause_for_manual_recovery`, and a `GitError` raised here would replace that - pause with a git failure. The unlink itself still raises, like every other - ledger write.""" - ledger = self.workspace.paths.deferred_work - try: - rel = ledger.relative_to(self.workspace.root).as_posix() - except ValueError: - return # ledger configured outside the workspace: not ours to reason about - if rel in (task.baseline_untracked or ()): + A second, inherited from keying on the attempt baseline rather than on the + pre-harvest moment: a ledger the dev SESSION wrote during this attempt is + dropped too, where the non-crash path would have kept it. That is the same + reference `baseline_untracked` and `baseline_commit` use, and the whole attempt + is being discarded — `safe_rollback` deletes every other file the session + created, and the ledger survives only because its folder is `keep`-shielded. + + The tracked probe is an OBSERVATION and degrades toward leaving the file alone; + the unlink itself raises, like every other ledger write.""" + if task.baseline_ledger_present is not False: + if task.baseline_ledger_present is None: + self.journal.append("ledger-baseline-unknown", story_key=task.story_key) return - try: - untracked = verify.untracked_files(self.workspace.root) - except verify.GitError as e: - self.journal.append( - "ledger-baseline-probe-failed", story_key=task.story_key, error=str(e) - ) + ledger = self.workspace.paths.deferred_work + if not ledger.is_file(): + return # nothing to revert + if self._ledger_is_gits_to_restore(task): return - if rel in untracked: - ledger.unlink(missing_ok=True) + ledger.unlink(missing_ok=True) def _stash_deferred_artifacts(self, task: StoryTask) -> None: """Move the deferred story's spec out of the artifacts dir into the run diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 32965e9c..2c53e074 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -160,6 +160,20 @@ 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 + # was the deferred-work ledger on disk when this attempt's baseline was captured? + # A plain `Path.is_file()`, never a git query — and that is the whole point. The + # ledger is commonly GITIGNORED (it lives under `output_folder`, which many + # projects ignore; `init` does not add that line but plenty of repos do, this one + # included), and every git-side signal here is ignore-blind: `baseline_untracked` + # and `verify.untracked_files` both come from `git ls-files --others + # --exclude-standard`. So in exactly the configuration where the question matters, + # git cannot answer it — an ignored ledger reads as BOTH "absent at the baseline" + # and "not created by this attempt". Read only by the crash-replay revert + # (`_drop_ledger_created_since_baseline`), where False is the ONLY value that + # authorizes deleting the ledger — so a task persisted before this field existed + # must rehydrate to None, never False (see from_dict). Scoped to the `_dev_phase` + # ENTRY, so attempts 2..N share it, exactly like baseline_commit. + baseline_ledger_present: bool | None = None spec_file: str | None = None commit_sha: str | None = None defer_reason: str | None = None @@ -252,6 +266,7 @@ def to_dict(self) -> dict[str, Any]: "followup_review_recommended": self.followup_review_recommended, "baseline_commit": self.baseline_commit, "baseline_untracked": self.baseline_untracked, + "baseline_ledger_present": self.baseline_ledger_present, "spec_file": self._serialized_spec_file(), "commit_sha": self.commit_sha, "defer_reason": self.defer_reason, @@ -299,6 +314,18 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": if d.get("baseline_untracked") is not None else None ), + # `is not None`, NOT the `bool(d.get(k, False))` idiom the neighbours use: + # a missing key has to stay None. Collapsing it to False would tell the + # crash-replay revert "this attempt created the ledger" for every task + # persisted before the field existed, and it would delete an operator's + # ledger on the first replay after upgrade. The explicit None test also + # keeps a persisted literal `false` as False, where a truthiness test + # would silently promote it back to "unknown". + baseline_ledger_present=( + bool(d["baseline_ledger_present"]) + if d.get("baseline_ledger_present") is not None + else None + ), spec_file=d.get("spec_file"), commit_sha=d.get("commit_sha"), defer_reason=d.get("defer_reason"), diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 3017dfac..a96ab1c7 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -279,6 +279,33 @@ 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. + + 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.""" + rc, out = _git(repo, "ls-files", "--", rel) + if rc != 0: + raise GitError(f"git ls-files -- {rel} failed in {repo}: {out}") + return bool(out) + + 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 diff --git a/tests/test_engine.py b/tests/test_engine.py index e2f7a935..3fba6477 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5842,6 +5842,30 @@ def test_harvest_tracked_ledger_still_reverted_on_retry(project): 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` / @@ -5982,6 +6006,213 @@ def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): assert HARVEST_A["summary"] in titles # documented, not accidental +def test_crash_replay_drops_a_gitignored_ledger_the_harvest_created(project): + """The fourth state, and the one git cannot describe: a GITIGNORED ledger. + + `baseline_untracked` and `verify.untracked_files` both come from + `git ls-files --others --exclude-standard`, so an ignored ledger is missing from + BOTH. Classifying on those two alone drops it into the "neither ⇒ 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. + + 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_never_unlinks_a_gitignored_ledger_present_at_the_baseline(project): + """Regression guard, not a bug repro — it passes on the unfixed build, and it is + here to fail against the WRONG fix. + + Teaching the classifier to see ignored files without also fixing the baseline + record would delete this ledger: an ignored ledger the operator already had is + absent from `baseline_untracked` exactly like a harvest-created one, so the two + are indistinguishable from git alone. That is why the baseline record is a plain + `Path.is_file()` and not a widened git probe. Do not delete this as redundant with + the untracked-at-the-baseline sibling: that one is shielded by + `baseline_untracked`, and this one is invisible to it.""" + 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", + ) + 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 + + 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 + assert HARVEST_A["summary"] in titles # documented cost, not an accident + + +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. + + "Absent at the baseline" is a creation for an untracked or ignored ledger, but + only a modification for this one — the harvest re-creates it, `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 the guard + the fix for the gitignored case 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.""" + 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_on_a_pre_upgrade_task(project): + """A task persisted before `baseline_ledger_present` existed rehydrates to None, + and None must not be read as "absent at the baseline". + + Released 0.9.1 shipped the harvest with no revert at all, so hands-off is exactly + what that run would have done — where guessing False would delete an operator's + ledger on the first crash-replay after upgrade. Deliberately the plain-untracked + layout, so the None path is isolated from the ignore path. 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["baseline_ledger_present"] # state.json as released 0.9.1 wrote it + resumed.state.tasks["1-1-a"] = StoryTask.from_dict(doc) + assert resumed.run().done == 1 + + # the harvest survives — the acknowledged cost of refusing to guess — and the + # skip is journalled rather than silent, so it is not mistaken for a no-op + assert [e.title for e in _ledger_entries(project)] == [HARVEST_A["summary"]] + assert "ledger-baseline-unknown" in [e["kind"] for e in resumed.journal.entries()] + assert isinstance(deferredwork.parse_ledger(""), list) # ledger stayed parseable + + def test_harvest_retry_reharvests_after_the_rollback(project): """The revert must lose nothing, and must not reach onto the fixable branch. diff --git a/tests/test_model.py b/tests/test_model.py index b6c6789f..ab78502d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -160,6 +160,25 @@ def test_sentinel_kind_defaults_empty_for_legacy_state(): assert StoryTask.from_dict(doc).sentinel_kind == "" +def test_baseline_ledger_present_round_trips(): + """All three states, and the False leg is not decorative: a truthiness + deserializer (`bool(d.get(k))`) turns a persisted `false` back into "unknown", + which silently disables the crash-replay revert it exists to drive.""" + for value in (True, False, None): + task = StoryTask(story_key="1-1-a", epic=1, baseline_ledger_present=value) + assert StoryTask.from_dict(task.to_dict()).baseline_ledger_present is value + + +def test_baseline_ledger_present_defaults_none_for_legacy_state(): + """None, NOT False — the one place the neighbours' `bool(d.get(k, False))` idiom + is wrong. False is the only value that authorizes deleting the ledger, so a task + persisted before this field existed would have its ledger unlinked on the first + crash-replay after upgrade.""" + doc = StoryTask(story_key="1-1-a", epic=1).to_dict() + del doc["baseline_ledger_present"] # state.json from before the field existed + assert StoryTask.from_dict(doc).baseline_ledger_present is None + + 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_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) From 0321876d32a0cb0124282d430ba88de2f1c4ebc5 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 30 Jul 2026 23:35:03 -0700 Subject: [PATCH 20/41] docs(changelog): qualify the two 0.9.1 claims round R9 falsified (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amended in place — 0.9.1 is unreleased on this branch. The harvest-revert bullet claimed the replayed attempt "recovers the ledger's pre-harvest state from the persisted baseline" without qualification, which is the exact sentence the gitignored case falsifies. It now names that case, says the baseline record is a filesystem check rather than a git query, and records that neither path unlinks a ledger git tracks. The worktree `_bmad/` bullet's "a seed that comes up short … pauses the run" gains its condition: only when the resolved dev primitive is a renderer stub. On a pre-BMAD-METHOD#2601 inline SKILL.md the short seed is journaled and the run proceeds. --- CHANGELOG.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da30e6be..eb085534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,12 +58,14 @@ editing. 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. 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/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"`. + 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`. On a pre-BMAD-METHOD#2601 inline `SKILL.md` nothing reads `_bmad/scripts/` at all, + so the short seed costs the run nothing and stays an ordinary journaled report. + `_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"`. - **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 @@ -92,9 +94,15 @@ editing. in-process, so a host death between the harvest and the rollback loses it; the replayed attempt recovers the ledger's pre-harvest state from the persisted baseline instead — deleting the file when the attempt created it, and leaving a tracked one to the reset, which - would otherwise be handed the dead attempt's harvest straight back. A defer still keeps its - harvested entries: `_stash_deferred_artifacts` has already moved the spec out of the artifacts - dir, so there the ledger entry is the finding's only surviving record. + would otherwise be handed the dead attempt's harvest straight back. That baseline is a plain + filesystem check recorded with the attempt's baseline commit, not a git query: a _gitignored_ + ledger — what a project that ignores `_bmad-output/` has, this one included — is absent from + `git ls-files --others --exclude-standard` whether the attempt created it or not, so a + git-derived baseline read it as tracked and left the dead attempt's finding behind. Neither + path unlinks a ledger git tracks, on the same reasoning: that one is the reset's to restore, + and deleting it would hand the next attempt's `git add -A` a deletion to commit. A defer still + keeps its harvested entries: `_stash_deferred_artifacts` has already moved the spec out of + the artifacts dir, so there the ledger entry is the finding's only surviving record. ## [0.9.0] — 2026-07-21 From 2e3c5602107e9360378209c9a4746307709d325e Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 07:52:50 -0700 Subject: [PATCH 21/41] fix(renderer): close the required-file surface, both layers (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round R10. The preflight and the worktree completeness check should cover the same three project-global files the renderer needs; today the preflight covers two and the completeness check one. C1 (Codex P1) — `_bmad/scripts/config_utils.py` joins the run-blocking preflight. `render_skill.py` imports it at module scope off sys.path[0], above its own `except (ConfigError, RenderError, OSError, UnicodeError, ValueError)`, so a half-present unit dies on a bare ModuleNotFoundError: the same result-less Stop, with the structured `HALT: <error>` line replaced by a traceback. One check id for both members (same finding, same remediation); `detail["script"]` becomes `detail["missing_scripts"]`, which has no shipped consumer — no renderer id exists at v0.9.0. Content-keyed on purpose: the sibling is required only when the INSTALLED `render_skill.py` actually names it, read the way `_is_renderer_stub` reads SKILL.md and failing open the same way. This gate has no severity filter and no `--force`, it ships in a patch release, and both upstream files are one commit old (c2530ea5, BMAD-METHOD#2601) — if a later bmm inlines or renames the helper the gate must go quiet, not refuse every run with a remediation that cannot fix it. C2 (Codex P2) — the seed completeness check gains a `_bmad/config.toml` leg. A repo that centralises only that file behind a symlink seeds a whole `_bmad/scripts/` and no config: the contain guard drops it with a bare `continue`, `_seed_bmad_tree` still reports the whole tree as seeded, and the repo-side preflight follows the symlink and passes. Measured end to end at 0321876: no journal line at all, the run dispatches, `run-complete`. The engine now intersects RENDERER_SEED_SENTINELS and names whichever leg fired — sending an operator to `_bmad/scripts` when it is intact is the drift the shared constants exist to prevent. C3 (found while validating C2) — the sentinels are reserved. A user `worktree_seed` entry spelling one exactly lands in `skipped` the moment the checkout already carries the path, and the `_is_under_bmad` strip is conditional on the merge having seeded something, which a checkout that commits its whole `_bmad/` never does. Confirmed by running provision_worktree: a forged sentinel CRITICAL-escalates a healthy run. Unreleased (the sentinel is this PR's), and C2 doubles the colliding surface, so it ships here. Verified by full 4-scenario sandbox E2E, each run twice (HEAD vs install.py/engine.py at 0321876) — the unit suite cannot see a renderer HALT. At 0321876 scenario 1 dispatches and reports `run-complete` with the sibling absent, and scenario 3 shows no `worktree-seed-skipped` line at all. Ten ablations, each seen to fail its paired test before restoring. --- src/bmad_loop/engine.py | 38 +++++---- src/bmad_loop/install.py | 131 ++++++++++++++++++++++++++---- tests/conftest.py | 9 +++ tests/test_cli.py | 54 ++++++++++--- tests/test_engine_worktree.py | 58 +++++++++++++- tests/test_install.py | 147 ++++++++++++++++++++++++++++++++++ 6 files changed, 393 insertions(+), 44 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 1f02b947..e8e98cf9 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -31,8 +31,8 @@ preference_escalations, ) from .install import ( - BMAD_SCRIPTS_SEED_REL, RENDERER_SCRIPT_MARKER, + RENDERER_SEED_SENTINELS, dev_primitive_or_default, provision_worktree, renderer_stub_resolved, @@ -633,26 +633,33 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) - if BMAD_SCRIPTS_SEED_REL in skipped_seeds and renderer_stub_resolved( + 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 `_bmad/scripts/` came up SHORT of the - # repo's. A renderer stub would then run without - # `render_skill.py`/`config_utils.py` and Stop having written nothing — on + # 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: `_bmad_scripts_seed_incomplete` can only see the REPO (it fires - # on any repo carrying a renderer-era `_bmad/scripts/` 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 `_bmad/scripts/` at all, so - # the short seed costs the run nothing and stays an ordinary + # 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 @@ -665,10 +672,11 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> # 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 " - f"{BMAD_SCRIPTS_SEED_REL} is incomplete in the worktree — the session " - f"would HALT without writing a spec. The usual cause is a symlinked " - f"_bmad/ pointing outside the repo, which worktree seeding cannot follow" + 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) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 85a01356..6fe1f8ab 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -106,11 +106,27 @@ 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 required=True; 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 +# 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" @@ -126,6 +142,15 @@ # 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) + # 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 @@ -232,6 +257,32 @@ def _is_renderer_stub(skill_dir: Path) -> bool: 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 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 @@ -359,8 +410,14 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: 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, ``_bmad/scripts/render_skill.py`` - is not. Per tree, since the primitive resolves per tree. + - ``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 @@ -425,15 +482,21 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: ) if _is_renderer_stub(skill_dir): resolved_stub = True - if not (project / RENDERER_SCRIPT_REL).is_file(): + 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 {RENDERER_SCRIPT_REL} is missing — the session would HALT " - f"without writing a spec; reinstall the BMad Method (bmm) module", - {"tree": tree, "skill": resolved, "script": RENDERER_SCRIPT_REL}, + 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}, ) ) for skill, markers in REVIEW_HUNTER_SKILLS.items(): @@ -860,6 +923,31 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: ) +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. + """ + return (repo_root / CENTRAL_CONFIG_REL).is_file() and not ( + worktree / CENTRAL_CONFIG_REL + ).is_file() + + def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool: """Install the bundled bmad-loop-* skills into each project skill tree. @@ -957,11 +1045,14 @@ def provision_worktree( 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. The same - channel also reports an INCOMPLETE `_bmad/scripts/` seed (see - _bmad_scripts_seed_incomplete), which is likewise silent otherwise. Whether THAT - one 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 - BMAD_SCRIPTS_SEED_REL). + 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). """ if not profiles and not seed_files and not seed_globs and not (repo_root / BMAD_DIR).is_dir(): return [] @@ -1023,8 +1114,18 @@ def provision_worktree( # 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 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). diff --git a/tests/conftest.py b/tests/conftest.py index 200fb5e4..c9469123 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -298,6 +298,15 @@ def install_base_skills(paths: ProjectPaths, trees=(".claude/skills", ".agents/s "# 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" +) def install_build_auto_skill( diff --git a/tests/test_cli.py b/tests/test_cli.py index a76eb5a8..41655a16 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1591,12 +1591,23 @@ def test_run_aborts_when_only_the_dev_shim_is_installed(project, monkeypatch, ca assert "bmad-build-auto" in err and "HALT" in err -def _install_renderer_stub_project(project, monkeypatch, *, script: bool, config: bool): +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`/`config` choose which of the two renderer prerequisites exist, so each - caller isolates one absence. Everything is laid down before the commit — the - preflight runs behind a clean-worktree gate.""" - from conftest import git, install_base_skills, install_build_auto_skill + `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) @@ -1606,7 +1617,11 @@ def _install_renderer_stub_project(project, monkeypatch, *, script: bool, config if script: rendered = project.project / "_bmad" / "scripts" / "render_skill.py" rendered.parent.mkdir(parents=True, exist_ok=True) - rendered.write_text("# renderer\n", encoding="utf-8") + 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) @@ -1622,18 +1637,31 @@ def test_run_aborts_when_the_renderer_script_is_missing(project, monkeypatch, ca """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, config=True) + _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 is there but `_bmad/config.toml` — the + """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, config=False) + _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 @@ -1641,10 +1669,10 @@ def test_run_aborts_when_the_renderer_central_config_is_missing(project, monkeyp def test_run_proceeds_once_the_renderer_files_are_present(project, monkeypatch, capsys): - """The clearing leg. Without it, ablating either `is_file()` predicate away - leaves both aborts above passing — the run would refuse a healthy stub project - and no test would notice.""" - _install_renderer_stub_project(project, monkeypatch, script=True, config=True) + """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 diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 10cbf9ec..6711a57f 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -29,7 +29,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, renderer_stub_resolved +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 @@ -780,6 +780,10 @@ def never(spec): # 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 @@ -789,6 +793,58 @@ def never(spec): assert not (Path(task.worktree_path) / "_bmad" / "scripts" / "render_skill.py").exists() +@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/`, diff --git a/tests/test_install.py b/tests/test_install.py index 0acf2e0a..7d74d184 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -857,6 +857,83 @@ def test_renderer_stub_without_its_script_fails_the_preflight(tmp_path): 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 @@ -1648,6 +1725,76 @@ def test_provision_worktree_reports_bmad_scripts_not_seeded(tmp_path): 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 From 87d71af7a0b88b08c832a56a6e81e9c6b19bde13 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 07:52:50 -0700 Subject: [PATCH 22/41] docs(changelog): amend the two renderer bullets for round R10 (#405) The preflight bullet said "Either way the stub's `uv run` exits `HALT:`", which C1 makes false for the new leg: a missing `config_utils.py` raises ModuleNotFoundError above the renderer's guard, so there is no `HALT:` line. Names the script unit and the content key. The worktree bullet gains the `_bmad/config.toml` leg and the sentinel reservation. --- CHANGELOG.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb085534..b95c9d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,11 +38,15 @@ editing. - **A renderer stub that cannot compose a prompt fails the preflight (#405).** Two new checks block `validate`/`run`/`sweep`/`resume`: `skills.dev-renderer`, when the resolved `SKILL.md` - is the new renderer stub (BMAD-METHOD#2601) but `_bmad/scripts/render_skill.py` is missing, - and `skills.dev-renderer-config`, when a stub resolved but `_bmad/config.toml` — the - renderer's one required config layer — is absent. Either way the stub's `uv run` exits `HALT:` - and the session Stops having written no spec; both files are project-global, so every story - after it does the same. They block rather than warn because only a green is untrustworthy here + 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 — and `skills.dev-renderer-config`, when a stub resolved but `_bmad/config.toml` — the + renderer's one required config layer — is absent. Every route ends in a session that Stops + having written no spec; all three files are project-global, so every story after it does the + same. A missing script or config exits `HALT:`; a missing sibling raises `ModuleNotFoundError` + above the renderer's own guard and loses even that line. The sibling is required only when the + installed `render_skill.py` actually imports it, so a later renderer that inlines or renames + the helper 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 both under its "NOT runnable as-is" banner. A third check, @@ -61,8 +65,13 @@ editing. 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`. On a pre-BMAD-METHOD#2601 inline `SKILL.md` nothing reads `_bmad/scripts/` at all, - so the short seed costs the run nothing and stays an ordinary journaled report. + `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"`. From 1e564e31f65368c2851c287b075cfe9c4c7c460d Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 09:24:36 -0700 Subject: [PATCH 23/41] fix(preflight): scope the skills gates to the dev-primitive roles (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — but the tree list was built from all three adapter roles. Triage's entire prompt surface is `/bmad-loop-sweep`, which this wheel bundles and `bmad-loop init` lays into that tree, so the skills checked and the skills triage needs have empty intersection. `[adapter.triage] name = "gemini"` beside a claude dev/review pair therefore demanded the whole bmm module in `.agents/skills`. The over-breadth is pre-existing on release/0.9.x; what makes it a hotfix is that #405's two renderer checks are problems rather than warnings, so a config 0.9.0 merely nagged about hard-blocks run/sweep/resume on 0.9.1. New `install.DEV_PRIMITIVE_ROLES = ("dev", "review")` — the same set `Engine._worktree_profiles` provisions, so what is gated and what a worktree carries stay one decision. `ROLES` is unchanged: `_make_adapters`, `policy.model-qualified` and `cmd_init` all legitimately need triage, and `cmd_init` is what lays the bundled sweep skill into that tree. Three legs, since `cmd_validate` reproduced the defect independently by building its own all-role list: - `_skill_trees` (gates `_require_base_skills` and the dry-run banner) - `cmd_validate`'s base-skills block, now fed a shared `dev_trees` local - `_validate_stories_queue`'s argument The `skills.base` ok line additionally gates on `dev_trees` rather than `profiles`: policy never validates an adapter name, so an unknown dev CLI beside a loadable triage one left `profiles` non-empty while nothing dev-side resolved, printing "upstream skills present ( + review hunters)" — a green sentence assembled from an empty probe. It can only tighten. Departure from the reported remedy: the renderer checks are not split out. `skills.dev-renderer-config` is once-per-project off a `resolved_stub` flag accumulated inside the same loop that emits `skills.base-*`, so it does not split cleanly, and splitting would undo 0b61083. Narrowed at the root instead. Seven tests, each seen failing with its own leg reverted; the two controls (`_skill_trees` on a split dev/review pair, `_require_base_skills` on a dev-tree renderer stub) were seen failing under `DEV_PRIMITIVE_ROLES = ("dev",)` and under deletion of the renderer checks respectively. --- CHANGELOG.md | 14 ++++ src/bmad_loop/cli.py | 67 +++++++++++----- src/bmad_loop/engine.py | 7 +- src/bmad_loop/install.py | 48 ++++++++--- tests/test_cli.py | 169 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 268 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b95c9d05..dc444276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,20 @@ editing. 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, but the renderer checks + above are problems rather than warnings, so a config 0.9.0 merely nagged about became a hard + block on 0.9.1. 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 renderer stub that cannot compose a prompt fails the preflight (#405).** Two 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 — diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 23337179..26dfafd4 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -119,6 +119,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 +388,18 @@ def cmd_validate(args: argparse.Namespace) -> int: except policy_mod.PolicyError as e: report.fail("policy", str(e)) + # 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) @@ -492,16 +502,20 @@ def cmd_validate(args: argparse.Namespace) -> int: {"role": role, "model": cfg.model, "profile": prof.name}, ) - base_trees = [p.skill_tree for p in profiles] - base_problems = install.missing_base_skills(project, base_trees) - 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(base_trees) + for tree in dict.fromkeys(dev_trees) if (name := install.resolve_dev_primitive(project, tree)) is not None ) ) @@ -509,12 +523,12 @@ def cmd_validate(args: argparse.Namespace) -> int: "skills.base", f"upstream skills present ({' + '.join(resolved)} + review hunters)", { - "trees": list(dict.fromkeys(base_trees)), + "trees": list(dict.fromkeys(dev_trees)), "dev_primitive": resolved, }, ) report.extend(base_problems) - report.extend(install.dev_primitive_warnings(project, base_trees)) + 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 @@ -644,16 +658,25 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: def _skill_trees(project: Path, pol) -> list[str]: - """The skill trees this run's adapters read, one per distinct adapter name. - - Shared by the real preflight and the dry-run banner so the two cannot drift: - a preview that claims "run would abort" has to key on exactly what makes run - abort. 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.""" + """The skill trees this run's dev-primitive adapters read, one per distinct name. + + 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. + + 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 trees = [] - for name in dict.fromkeys(pol.adapter.resolved(role).name for role in ROLES): + for name in dict.fromkeys( + pol.adapter.resolved(role).name for role in install.DEV_PRIMITIVE_ROLES + ): try: trees.append(get_profile(name, project).skill_tree) except ProfileError: @@ -768,10 +791,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: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index e8e98cf9..e3a21f0f 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -549,7 +549,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) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 6fe1f8ab..1cf78f76 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -90,6 +90,22 @@ 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 @@ -176,9 +192,12 @@ } # 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 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. @@ -330,11 +349,13 @@ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding """Problems for stories mode's stricter dev-primitive requirement. Sprint mode drives any dev primitive; stories mode needs the folder+id - dispatch flow, which older skill versions lack. For each active CLI skill - tree, 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. + 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 @@ -381,11 +402,12 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: 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 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. + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 41655a16..611c94d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -48,6 +48,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" @@ -3491,14 +3503,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 @@ -3555,6 +3571,92 @@ def test_validate_names_the_resolved_dev_primitive(project, capsys, monkeypatch) 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 @@ -4111,6 +4213,67 @@ def test_dry_run_stories_previews_the_resolved_primitive(project, capsys): 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 897988f0af150d007a563759aeccf3f6dd2041eb Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 09:56:12 -0700 Subject: [PATCH 24/41] fix(worktree): pause when the skills seed comes up short (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review finding on #406. `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. `not src.is_dir()` means the repo genuinely lacks the skill, which the run-start preflight already refused — skipping is right. A containment failure does not: the skill IS there, as a symlink to a shared machine-wide BMad install, and `missing_base_skills` stats through the link and passes. The guard's comment claimed the preflight covered both legs; it covers only the first. Measured, with the symlink as the only variable: 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. Every session then stalls on `Unknown command` having written nothing — the failure the preflight exists to prevent, reached by a project that passed it. Same environment-fault shape as the renderer sentinels: the seed reads the same repo for every unit, so no repair session fixes it and no later story escapes it. New `install.base_skills_seed_incomplete` reports the rels through the existing `worktree-seed-skipped` channel and the engine escalates before dispatch, naming the skills rather than the renderer surface. Two deliberate differences from the renderer legs: - NO era gate. A missing skill stalls a session whether its SKILL.md renders or is inline, so gating on `renderer_stub_resolved` would let the whole pre-#2601 world dispatch into the stall. - The engine re-probes instead of reading `skipped_seeds` back, so a user `worktree_seed` entry spelling a skill rel cannot forge a pause — which is why this needs no equivalent of the RENDERER_SEED_SENTINELS forgery filter. Gated on the repo having the skill and the worktree lacking it, so a committed (checked-out) symlink and a tracked skill tree are both unaffected. Pre-existing since 1c17828 (0.6.5) and byte-identical on release/0.9.x — not a #405 regression, fixed here because #405 owns this seam this cycle. Three tests, each seen failing under its own ablation: dropping the report kills the renderer-era one, deleting the escalation kills both escalation tests, and era-gating it the way the sentinel legs are kills the inline one alone. The must-not-fire control dies when the worktree conjunct is dropped from the predicate. The four existing seed tests stay green under all four ablations. --- CHANGELOG.md | 14 +++ src/bmad_loop/engine.py | 29 +++++++ src/bmad_loop/install.py | 57 ++++++++++++- tests/test_engine_worktree.py | 155 ++++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc444276..17373c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,20 @@ editing. 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 era condition: a missing skill + stalls a session whether its `SKILL.md` renders or is inline. A skill tree that is committed + (symlink and all) is checked out into the worktree normally and is unaffected. Pre-existing + since 0.6.5; found while reviewing this release. + - **A renderer stub that cannot compose a prompt fails the preflight (#405).** Two 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 — diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index e3a21f0f..e69961ce 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -33,6 +33,7 @@ from .install import ( RENDERER_SCRIPT_MARKER, RENDERER_SEED_SENTINELS, + base_skills_seed_incomplete, dev_primitive_or_default, provision_worktree, renderer_stub_resolved, @@ -638,6 +639,34 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) + # The skills half of the same fault, and it needs no era gate: a worktree + # missing the dev primitive or a review hunter stalls the session on `Unknown + # command` whether that SKILL.md renders or is inline. 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 skills the repo has " + f"({', '.join(absent_skills)}) — the session would stall on `Unknown " + f"command` having written nothing. The usual cause is a skill tree " + f"symlinked to a shared BMad install outside the repo, which worktree " + f"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) 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] diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 1cf78f76..469d458d 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -945,6 +945,44 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: ) +def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]: + """The ``<tree>/<skill>`` rels the repo resolves but the worktree does not carry. + + 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 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. + + 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.""" + missing: list[str] = [] + for tree in dict.fromkeys(trees): + for skill in BASE_SKILLS: + if (repo_root / tree / skill).is_dir() and not (worktree / tree / skill).is_dir(): + missing.append(f"{tree}/{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. @@ -1161,8 +1199,15 @@ def provision_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 the dev primitive (under EITHER - # name — BASE_SKILLS lists both eras) and the review hunters. Skip silently - # when the main repo lacks them — the run-start preflight reports it. + # name — BASE_SKILLS lists both eras) and the review hunters. + # + # Two legs, and only one of them is safe to skip silently. `not src.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. That leg is reported below via + # `base_skills_seed_incomplete`, which re-asks the question of the result + # rather than trusting this loop's bookkeeping. for skill in BASE_SKILLS: dst = tree_dir / skill if dst.exists(): @@ -1172,6 +1217,14 @@ def provision_worktree( continue _copy_traversable(src, dst) + # 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. for profile in profiles: diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 6711a57f..10a35db3 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -793,6 +793,161 @@ def never(spec): assert not (Path(task.worktree_path) / "_bmad" / "scripts" / "render_skill.py").exists() +def _symlink_skill_tree( + project, shared: Path, tree: str = ".claude/skills", *, renderer_stub: bool = False +) -> None: + """Point every upstream skill 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.""" + from conftest import RENDERER_STUB_SKILL_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 (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" + ) + 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) + + +@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"] + assert escalated and ".claude/skills/" in escalated[0]["reason"] + + +@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_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 From 7344440fd1f2e62afe47028723e4f753ce26aee7 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 11:11:03 -0700 Subject: [PATCH 25/41] fix(worktree): scope the skills seed gate to the dispatched primitive (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review finding on #406, against the gate the previous round added. `base_skills_seed_incomplete` iterated all of `BASE_SKILLS` and the engine treats any rel it returns as a CRITICAL escalation that pauses the whole run before dispatch. But `BASE_SKILLS` is a copy-if-PRESENT catalog — "every non-bundled skill that MIGHT need copying" — and it names both primitive eras precisely because naming both is free when the only question is "copy it if it is there". Read as a requirement set it demands one skill too many. Measured end to end, both directions. A repo whose `bmad-build-auto` is an ordinary dir beside a `bmad-dev-auto` symlinked to a shared machine-wide install passes the preflight, seeds the worktree with every skill a session names, and was still escalated — over a shim no prompt spells, since `Engine._dev_skill` resolves the name from disk and picks the new one. The mirror case escalated too: a resolved legacy install beside a `SKILL.md`-less `bmad-build-auto` dir, which nothing can resolve and the preflight never stats, but `is_dir()` sees. Both halt the backlog with a reason claiming a stall that cannot happen. The gate now skips the era this run will not invoke, resolved through `dev_primitive_or_default` — the same call the engine makes to spell the prompt, so what is gated and what is dispatched stay one decision. That is the doctrine the tree-scoping round already set: a skill gated here that no session reads refuses runs over nothing. `bmad-review` stays gated, against the finding's suggested shape. It is 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 lacking one the repo HAS really does break those forwards; the repo-has-it conjunct already keeps the pre-merge case silent. Preflight-optional and seeded-or-not are different questions. Not a released regression: both the two-era `BASE_SKILLS` and this gate landed in this PR. Tests. Four new ones, each with the ablation that reddens it: the unused era is ignored (delete the era skip), an unresolvable new-primitive dir is ignored (same), a dropped LEGACY primitive is still reported (hardcode the skip to the legacy name — the bound that stops "scope to the resolved primitive" degenerating into "always check the new name"), and a dropped `bmad-review` is still reported (skip it, the finding's literal remedy). Plus an engine-level run that must reach DONE. `_symlink_skill_tree` gains an explicit `skills` list and a `_real_skill_dirs` sibling: it symlinked the whole dispatched set at once, so the resolved primitive was always among the casualties and no fixture could express the mixed shape — which is why the over-breadth shipped green. One existing assert tightened. `..._pauses_an_inline_primitive_too` checked only for the `.claude/skills/` prefix, which the three hunters satisfy on their own, so it stayed GREEN under an ablation that stopped checking the primitive entirely. It now names `bmad-build-auto`, and reddens. --- CHANGELOG.md | 11 +++-- src/bmad_loop/engine.py | 8 ++-- src/bmad_loop/install.py | 39 +++++++++++++-- tests/test_engine_worktree.py | 90 ++++++++++++++++++++++++++++++++--- tests/test_install.py | 70 +++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17373c22..9035e440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,10 +59,13 @@ editing. 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 era condition: a missing skill - stalls a session whether its `SKILL.md` renders or is inline. A skill tree that is committed - (symlink and all) is checked out into the worktree normally and is unaffected. Pre-existing - since 0.6.5; found while reviewing this release. + 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. A skill tree that is committed (symlink and all) is + checked out into the worktree normally and is unaffected. Pre-existing since 0.6.5; found while + reviewing this release. - **A renderer stub that cannot compose a prompt fails the preflight (#405).** Two new checks block `validate`/`run`/`sweep`/`resume`: `skills.dev-renderer`, when the resolved `SKILL.md` diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index e69961ce..31d56c6b 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -639,9 +639,11 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) - # The skills half of the same fault, and it needs no era gate: a worktree - # missing the dev primitive or a review hunter stalls the session on `Unknown - # command` whether that SKILL.md renders or is inline. Re-probed rather than + # The skills half of the same fault, and it needs no RENDERER-era gate: a + # worktree missing the dev primitive or a review hunter stalls the session on + # `Unknown command` whether that SKILL.md renders or is inline. 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, diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 469d458d..61956047 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -964,9 +964,10 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence 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 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. + 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 @@ -974,10 +975,40 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence 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.""" + constants exist to prevent. + + 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``; those are the same path + unless a project overrides it — tracked as #414.) + + 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 if (repo_root / tree / skill).is_dir() and not (worktree / tree / skill).is_dir(): missing.append(f"{tree}/{skill}") return missing diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 10a35db3..2232951c 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -10,6 +10,7 @@ import os import shutil +from collections.abc import Sequence from pathlib import Path import pytest @@ -793,24 +794,50 @@ def never(spec): 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 + project, + shared: Path, + tree: str = ".claude/skills", + *, + renderer_stub: bool = False, + skills: Sequence[str] | None = None, ) -> None: - """Point every upstream skill in ``tree`` at a shared install OUTSIDE the repo — - how a machine-wide BMad install is wired, and the one shape provisioning cannot - follow. + """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.""" + 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 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 (DEV_PRIMITIVE_NEW, *REVIEW_HUNTER_SKILLS): + 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 @@ -915,7 +942,56 @@ def never(spec): assert summary.paused and summary.escalated == 1 escalated = [e for e in Journal(engine.run_dir).entries() if e["kind"] == "story-escalated"] - assert escalated and ".claude/skills/" in escalated[0]["reason"] + # 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") diff --git a/tests/test_install.py b/tests/test_install.py index 7d74d184..640d4144 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -10,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, ) @@ -604,6 +609,71 @@ 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 _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 asks `is_dir()`, never how it got that way). + """ + 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_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 From b96b9dc0b7e2a825346d1f0184e5d175c7c4df48 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 13:33:56 -0700 Subject: [PATCH 26/41] fix(worktree): merge skill seeds per file, gate on required files (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worktree skill copy skipped a destination directory that already existed, so a checkout carrying one file of a skill dir received zero bytes of the rest — and `base_skills_seed_incomplete` asked `is_dir()` on both sides, calling that state complete. The preflight has always asked per file, so a partial skill dir passed all three layers and dispatched a session whose step-04 had nothing to read. Both loops now merge per FILE (`_merge_traversable`), and the gate asks the preflight's own named surface (`SKILL.md` plus the skill's `BASE_SKILLS` markers), naming the missing file rather than the directory. Containment is per file too, so a single skill file symlinked out of the repo is reported instead of followed silently. The merge is what makes a gate-only fix wrong: a directory the copier refuses to fill can never be cleared by the operator, so the pause would repeat on every resume. --- CHANGELOG.md | 11 +- src/bmad_loop/engine.py | 18 +-- src/bmad_loop/install.py | 129 +++++++++++++++++--- tests/test_engine_worktree.py | 167 +++++++++++++++++++++++++ tests/test_install.py | 221 ++++++++++++++++++++++++++++++++-- 5 files changed, 510 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9035e440..982b9faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,9 +63,14 @@ editing. 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. A skill tree that is committed (symlink and all) is - checked out into the worktree normally and is unaffected. Pre-existing since 0.6.5; found while - reviewing this release. + 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 compares the same required files the run-start preflight does — naming the + one that is missing instead of the directory. Containment is per file too, so a single skill + file symlinked out of the repo is reported like a symlinked tree instead of being followed + silently. A skill tree that is committed (symlink and all) + is checked out into the worktree normally and is unaffected. Pre-existing since 0.6.5; found + while reviewing this release. - **A renderer stub that cannot compose a prompt fails the preflight (#405).** Two new checks block `validate`/`run`/`sweep`/`resume`: `skills.dev-renderer`, when the resolved `SKILL.md` diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 31d56c6b..92b47a09 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -640,8 +640,9 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> "worktree-seed-skipped", story_key=task.story_key, entries=skipped_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 stalls the session on - # `Unknown command` whether that SKILL.md renders or is inline. It does carry a + # worktree missing the dev primitive or a review hunter — or a required file + # inside one — 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 @@ -653,11 +654,14 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> ) if absent_skills: reason = ( - f"the worktree is missing upstream skills the repo has " - f"({', '.join(absent_skills)}) — the session would stall on `Unknown " - f"command` having written nothing. The usual cause is a skill tree " - f"symlinked to a shared BMad install outside the repo, which worktree " - f"seeding cannot follow" + 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"required file. The usual cause is a skill directory, or one file " + f"inside one, symlinked to a shared BMad install outside the repo, " + f"which worktree seeding cannot follow; a checked-out symlink whose " + f"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) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 61956047..8c141843 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -804,6 +804,36 @@ def _copy_traversable(src, dst: Path) -> None: dst.write_bytes(src.read_bytes()) +def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = None) -> None: + """Recursively 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 ``.iterdir()`` 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. + """ + if src.is_dir(): + for child in src.iterdir(): + _merge_traversable(child, dst / child.name, worktree, repo_root) + return + if repo_root is not None and not Path(str(src)).resolve().is_relative_to(repo_root): + return + if not dst.resolve().is_relative_to(worktree) or dst.exists(): + return + dst.parent.mkdir(parents=True, exist_ok=True) + _copy_traversable(src, dst) + + 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 @@ -945,8 +975,37 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: ) +def _absent_skill_files( + repo_skill: Path, worktree_skill: Path, markers: Sequence[str] +) -> list[str]: + """The REQUIRED files of one skill dir the repo has and the worktree does not. + + The required surface is the one :func:`missing_base_skills` already asserts at + run start — ``SKILL.md`` plus the skill's :data:`BASE_SKILLS` markers — so the + two layers demand the same set and a worktree the preflight calls healthy is + never refused here. Deliberately *not* rglob parity with the repo's dir: that is + right for ``_bmad/scripts/`` (see :func:`_bmad_scripts_seed_incomplete`) precisely + because it has no named surface, but a skill dir does, and parity would halt the + whole backlog over a repo-only ``README.md`` no session reads, with no remedy + available to the operator. The bounded blind spot is an undeliverable + *non-required* file, reported by neither layer; the remedy for one that turns out + to matter is to add it to the marker tuple, where both layers pick it up at once. + + Each leg is conjuncted on the REPO's copy having the file, so a project whose + install predates a marker is not asked for something no copy could have delivered. + ``is_file`` rather than ``exists`` throughout: a destination occupied by a + directory is not the file the skill needs. + """ + return [ + rel + for rel in ("SKILL.md", *markers) + if (repo_skill / rel).is_file() and not (worktree_skill / rel).is_file() + ] + + def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]: - """The ``<tree>/<skill>`` rels the repo resolves but the worktree does not carry. + """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` @@ -977,6 +1036,23 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence ``_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 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. + + 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 @@ -1006,11 +1082,20 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence unused_era = {DEV_PRIMITIVE_NEW, DEV_PRIMITIVE_LEGACY} - { dev_primitive_or_default(repo_root, tree) } - for skill in BASE_SKILLS: + for skill, markers in BASE_SKILLS.items(): if skill in unused_era: continue - if (repo_root / tree / skill).is_dir() and not (worktree / tree / skill).is_dir(): + repo_skill = repo_root / tree / skill + worktree_skill = worktree / tree / skill + if not (repo_skill / "SKILL.md").is_file(): + continue + if not (worktree_skill / "SKILL.md").is_file(): missing.append(f"{tree}/{skill}") + continue + missing += [ + f"{tree}/{skill}/{rel}" + for rel in _absent_skill_files(repo_skill, worktree_skill, markers) + ] return missing @@ -1113,7 +1198,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/; @@ -1219,34 +1307,37 @@ def provision_worktree( 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 the dev primitive (under EITHER # name — BASE_SKILLS lists both eras) and the review hunters. # - # Two legs, and only one of them is safe to skip silently. `not src.is_dir()` + # 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. That leg is reported below via - # `base_skills_seed_incomplete`, which re-asks the question of the result - # rather than trusting this loop's bookkeeping. + # 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. The per-file skips are deliberately 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(): - continue - src = (repo_root / tree / skill).resolve() - if not src.is_relative_to(repo_root) or not src.is_dir(): + src_root = repo_root / tree / skill + if not src_root.resolve().is_relative_to(repo_root) or not src_root.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 diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 2232951c..8bb52b5a 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -849,6 +849,36 @@ def _symlink_skill_tree( (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 required file, + 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. + + 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) + + @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 @@ -1024,6 +1054,143 @@ def test_a_tracked_symlinked_skill_tree_does_not_pause(project, tmp_path): 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() + + +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 diff --git a/tests/test_install.py b/tests/test_install.py index 640d4144..d6e3f242 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -575,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" @@ -584,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() @@ -609,11 +615,114 @@ 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}"] + + 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 asks `is_dir()`, never how it got that way). + `base_skills_seed_incomplete` reads: it asks per required FILE behind a `SKILL.md` + precondition, never how the worktree's copy of one got there. """ wt, repo = tmp_path / "wt", tmp_path / "repo" for root in (repo, wt): @@ -674,6 +783,103 @@ def test_base_skills_seed_incomplete_reports_a_dropped_bmad_review(tmp_path): 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): + """Every required file is conjuncted on the REPO's copy having it, so an install + predating a marker 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.""" + 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_base_skills_seed_incomplete_ignores_a_file_no_layer_requires(tmp_path): + """What rejects rglob-parity-with-the-repo as this gate's surface. Parity is right + for `_bmad/scripts/`, which has no named surface, but a skill dir has one — and it + is the surface the preflight already asserts, so the two layers demand the same set + and a worktree the preflight called healthy is never refused here. Parity instead + would halt the whole backlog over a repo-only README.md no session reads, with no + remedy available to the operator.""" + wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS) + repo_skill = repo / tree / DEV_PRIMITIVE_NEW + (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") + + assert base_skills_seed_incomplete(wt, repo, [tree]) == [] + + +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]) == [] + + 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 @@ -1253,9 +1459,10 @@ def test_missing_stories_support_probes_the_resolved_primitive(tmp_path): 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 whole-dir, so review-prompts/ (which customize.toml's - layers point at) has to ride along — a markers-only copy would leave every - review layer unresolvable inside the worktree.""" + 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 wt, repo = tmp_path / "wt", tmp_path / "repo" From 64c4f2c1aa647269878995db58bcd905b10ea2e7 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 16:45:11 -0700 Subject: [PATCH 27/41] fix(engine): persist the pre-harvest ledger snapshot (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex R15 finding C1. `_dev_phase` held its pre-harvest deferred-work snapshot in a plain local, so a host death between `_harvest_spec_deferrals` and the non-fixable RETRY rollback lost it. The replay leg then fell back to a persisted BOOLEAN, `StoryTask.baseline_ledger_present`, which could answer only "was there a ledger 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, and nothing else reverts one: `reset --hard` skips ignored paths, there is no `git clean`, and `_safe_reset`'s `keep` shields the artifacts dir besides. The successful retry then committed a finding describing code that no longer existed. The snapshot is now persisted with the attempt — `pre_harvest_ledger` + `pre_harvest_ledger_captured` — armed with a `_save()` before the harvest and cleared at four sites once that attempt's decision is acted on, so a finished story carries no ledger copy in state.json. Live and replayed paths restore the same bytes through one helper. The presence bit and `_drop_ledger_created_since_baseline` are deleted; net-subtractive. The split of text and flag is load-bearing and the `from_dict` spelling is the trap: `None` text means "no ledger existed, so the restore UNLINKS", which is a different state from "nothing was captured, hands off". A persisted `""` must rehydrate as `""`, so the text uses `is not None` and not the natural `str(d.get(k, "")) or None` — ablation viii shows that spelling rehydrating a persisted `None` as the literal string "None" and writing it into the ledger. The arm also moved AHEAD of `_post_dev_state_sync` (plan amendment F1, decided this round rather than inherited from the brief). The sweep engine closes a bundle's ledger entries `status: done` there, and a snapshot taken after it wrote those closes back on top of the `reset --hard` that had just correctly reverted them. A surviving close is worse than a surviving finding: `deferredwork.open_ids` only ever re-bundles open entries, so no later sweep would look at that id again — the work is silently lost, not merely mis-recorded. `test_bundle_ledger_close_reverted_on_non_fixable_retry` is the discriminator; nothing in the existing ledger family can see the snapshot's position, because there the ledger is only ever touched by the harvest. Ablations: 14 rows, each run against the WHOLE suite, every predicted RED and GREEN confirmed. The rows that deliberately stayed green were read too — the entire live-path family survives every replay-only ablation, and the review-leg defer control cannot redden under "restore on the DEFER path" (its ledger is tracked and site 1 has already disarmed), which is why the dev-leg defer test exists. Row i is the one ablation with no behavioural witness, and it is stated rather than papered over: `run()`'s `finally: self._save()` re-persists the armed fields on any soft crash a fixture can inject, so deleting the arm's own `_save()` leaves every crash-replay test green. Only the durability test `test_the_pre_harvest_snapshot_is_persisted_before_the_harvest_runs`, which reads state.json off disk at the moment the harvest runs, pins it. A replayed non-`completed` result is vacuous rather than untested: `_resumable_session` refuses any record that is not completed with a `result_json` and synthesizes `status="completed"` on what it hands back. The reachable neighbour — a LIVE non-completed attempt reaching the same restore leg with nothing armed — is pinned by `test_a_non_completed_attempt_never_touches_the_ledger`. 2858 collected (2857 passed, 1 skipped); full `trunk check` clean. pyright does not exist on the 0.9.x line. Three items from an independent clause-by-clause verification of the CHANGELOG against source, folded in rather than deferred. (1) Clear site 0 moved ABOVE the `pre_dev_phase` veto check: `_vetoed` drives the task terminal and saves, so a veto below it persisted a dead task still carrying the ledger copy. (2) "Neither path unlinks a ledger git tracks" was stale prose from the deleted two-branch shape — there is one path now. (3) The neighbouring harvest bullet claimed "a retry ... never re-files one"; a NON-fixable retry re-files by design, since this bullet's revert removes the entry first (`test_harvest_retry_reharvests_after_ the_rollback` asserts two harvests). Scoped to the fixable retry and the asymmetry named. --- CHANGELOG.md | 37 ++-- src/bmad_loop/engine.py | 267 +++++++++++++------------- src/bmad_loop/model.py | 59 +++--- tests/test_engine.py | 401 +++++++++++++++++++++++++++++++++------- tests/test_model.py | 50 +++-- tests/test_sweep.py | 62 +++++++ 6 files changed, 626 insertions(+), 250 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982b9faa..bb5190a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,9 +116,10 @@ editing. 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 retry, - a resume replay or a second review pass never re-files one — including after a sweep has - already marked it done. Malformed items do not block their well-formed siblings: the loss is + `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 @@ -135,19 +136,23 @@ editing. 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. That snapshot is - in-process, so a host death between the harvest and the rollback loses it; the replayed - attempt recovers the ledger's pre-harvest state from the persisted baseline instead — - deleting the file when the attempt created it, and leaving a tracked one to the reset, which - would otherwise be handed the dead attempt's harvest straight back. That baseline is a plain - filesystem check recorded with the attempt's baseline commit, not a git query: a _gitignored_ - ledger — what a project that ignores `_bmad-output/` has, this one included — is absent from - `git ls-files --others --exclude-standard` whether the attempt created it or not, so a - git-derived baseline read it as tracked and left the dead attempt's finding behind. Neither - path unlinks a ledger git tracks, on the same reasoning: that one is the reset's to restore, - and deleting it would hand the next attempt's `git add -A` a deletion to commit. A defer still - keeps its harvested entries: `_stash_deferred_artifacts` has already moved the spec out of - the artifacts dir, so there the ledger entry is the finding's only surviving record. + frontmatter is never mutated and the next attempt re-harvests from it. The snapshot is taken + ahead of the orchestrator's own ledger writes too, so a sweep bundle's `status: done` closes + revert along with the code they claimed to resolve — a surviving close is worse than a + surviving finding, since `open_ids` only ever re-bundles open entries and no later sweep would + look at that id again. 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`. 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. ## [0.9.0] — 2026-07-21 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 92b47a09..59139739 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1815,6 +1815,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: @@ -1825,26 +1835,8 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # would shift the rollback/squash reference onto the completed # session's own tree. task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root)) - # ...and whether the deferred-work ledger itself was on disk. Deliberately - # NOT a git question: the ledger is commonly gitignored, and both git-side - # signals above exclude ignored paths, so for exactly that layout a - # gitignored ledger reads as BOTH "absent at the baseline" and "not created - # by this attempt" — and the crash-replay revert below then leaves the dead - # attempt's finding behind. Widening the NOW probe alone would be worse: an - # ignored ledger the operator already had would classify as attempt-created - # and get deleted. This bit is the symmetric half, and it is captured with - # the other two so all three always describe the same moment. - task.baseline_ledger_present = self.workspace.paths.deferred_work.is_file() feedback: Path | None = None while True: - # Pre-harvest ledger snapshot for the non-fixable-RETRY revert below, - # re-armed every attempt. The flag is separate from the text on - # purpose: `ledger_before is None` means the ledger did NOT EXIST when - # the snapshot was taken (the first-ever harvest creates it, and the - # restore then has to unlink), which is a different state from "no - # snapshot was taken at all" (nothing to restore, hands off). - ledger_before: str | None = None - ledger_snapshotted = False replayed = resume_result is not None if resume_result is None: # a resumed result replays the attempt it was recorded under, so @@ -1879,6 +1871,33 @@ 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 — `_post_dev_state_sync`, where the sweep engine closes + # the bundle's entries `status: done`, as well as + # `_harvest_spec_deferrals`. Both of those describe work the rollback + # is about to discard, and a close is the more expensive of the two to + # get wrong: `deferredwork.open_ids` only ever re-bundles `open` + # entries, so a close that outlives its code hides the work from every + # future sweep. Snapshotting after the sync would write that close + # back on top of a `reset --hard` that had just correctly reverted it. + # + # NOT on a replayed attempt: 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. + if not replayed: + task.pre_harvest_ledger = self._ledger_text() + task.pre_harvest_ledger_captured = True + 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 — @@ -1895,19 +1914,9 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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. Snapshot the - # ledger here so the rollback half is enforced explicitly, at the - # RETRY branch below. - # - # NOT on a replayed attempt: the host died somewhere between the - # dead process's harvest and its rollback, so what is on disk now is - # already POST-harvest and snapshotting it would restore the very - # edit the rollback is meant to undo — worse than not restoring, on - # a tracked ledger, because the reset alone would have reverted it. - # The `finally` recovers the attempt baseline instead. - if not replayed: - ledger_before = self._ledger_text() - ledger_snapshotted = True + # 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. @@ -1945,6 +1954,11 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None ) self._save() if decision.action == Action.PROCEED: + # 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. + self._disarm_ledger_snapshot(task) self._emit("post_dev_phase", task) if self._run_workflows("post_dev_phase", task, task.attempt): return False @@ -1959,29 +1973,40 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None try: self._rollback_or_pause(task) finally: - # The reset discards the attempt but NOT its harvest: 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 — the - # next attempt re-harvests from it. + # 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. # - # Two paths because the snapshot is a plain local and a host - # death loses it: a live attempt restores its own snapshot; a - # REPLAYED one recovers the attempt baseline from persisted - # state instead. Restoring a freshly-read snapshot there would - # write the dead attempt's harvest back — see - # _drop_ledger_created_since_baseline. - if ledger_snapshotted: - self._restore_ledger(task, ledger_before) - elif replayed: - self._drop_ledger_created_since_baseline(task) + # 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) + # 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) @@ -2639,14 +2664,13 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> 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 - immediately before this call and restores it around `_rollback_or_pause` - — that restore, not the reset, is what makes the revert unconditional. - On a REPLAYED attempt that snapshot no longer exists (it is a local, and the - host died holding it), so the revert falls back to recovering the attempt - baseline from persisted state — see - `_drop_ledger_created_since_baseline`, which also documents the one case - that stays unrecoverable and is therefore left alone. + would survive (#405). `_dev_phase` therefore snapshots the ledger ahead of + this call — ahead of `_post_dev_state_sync` too, so a sweep bundle's + `status: done` closes revert with the code they claim to resolve — 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, @@ -3434,73 +3458,64 @@ def _restore_ledger(self, task: StoryTask, snapshot: str | None) -> None: ledger.parent.mkdir(parents=True, exist_ok=True) ledger.write_text(snapshot, encoding="utf-8") - def _drop_ledger_created_since_baseline(self, task: StoryTask) -> None: - """Revert a harvest whose in-process snapshot died with the host. - - A crash between `_harvest_spec_deferrals` and the non-fixable RETRY rollback - leaves the attempt resumable: `_resumable_session` replays the recorded - result and `_dev_phase` re-enters its loop from the top. The pre-harvest - snapshot is a plain local, so it is gone — but ``baseline_ledger_present`` is - persisted with the rest of the attempt baseline and deliberately not - re-captured on a replay, so it still answers the only question that matters: - - - PRESENT at the baseline — the harvest appended to a ledger that was already - there. Its pre-harvest content is genuinely unrecoverable, so hands off - rather than guess; the harvest's own fingerprint dedup keeps a re-harvest - quiet. Covers a tracked, an untracked and an ignored ledger alike, which is - the point: only the filesystem can speak for all three. - - ABSENT at the baseline, and git does not own the path — this attempt created - it (the first-ever harvest does exactly this), so unlink. - - ABSENT at the baseline, but TRACKED — the operator had deleted a committed - ledger without committing that; `_rollback_or_pause`'s `reset --hard` has - restored it and deleting it here would lose a file no rollback asked to lose. - See `_ledger_is_gits_to_restore`. - - ``None`` — a task persisted before the bit existed. Released 0.9.1 carried - the harvest with no revert at all, so doing nothing is exactly what that run - would have done; refusing to guess also matches `safe_rollback`, which - removes nothing when `baseline_untracked` is None. Journalled, not silent. - - Why a persisted filesystem bit rather than git: `baseline_untracked` and - `verify.untracked_files` both come from `git ls-files --others - --exclude-standard`, so a GITIGNORED ledger is missing from both and git keeps - no record of an ignored file's prior existence at all. Classifying on those two - put every ignored ledger in the "tracked ⇒ the reset already reverted it" - bucket, where nothing reverts it — the reset skips ignored paths, this module - never runs `git clean`, and `_safe_reset`'s `keep` shields the artifacts dir - besides. Making only the NOW probe ignore-aware would have been worse: an - ignored ledger the operator already had would have classified as - attempt-created and been deleted. - - The tracked case is also why the replayed path cannot simply reuse - `_restore_ledger` with a freshly-read snapshot: on a tracked ledger that would - re-create the very edit the reset just undid. - - One asymmetry, deliberate: under the default ``scm.rollback_on_failure = off`` - no reset runs at all, so a *tracked* ledger keeps its harvest edit here where - the non-crash path would have restored pre-harvest text. The run is paused and - the operator's own `reset --hard` reverts it; inventing a checkout of the - baseline blob inside a `finally` that is already unwinding a pause is the - worse trade. - - A second, inherited from keying on the attempt baseline rather than on the - pre-harvest moment: a ledger the dev SESSION wrote during this attempt is - dropped too, where the non-crash path would have kept it. That is the same - reference `baseline_untracked` and `baseline_commit` use, and the whole attempt - is being discarded — `safe_rollback` deletes every other file the session - created, and the ledger survives only because its folder is `keep`-shielded. - - The tracked probe is an OBSERVATION and degrades toward leaving the file alone; - the unlink itself raises, like every other ledger write.""" - if task.baseline_ledger_present is not False: - if task.baseline_ledger_present is None: - self.journal.append("ledger-baseline-unknown", story_key=task.story_key) - return - ledger = self.workspace.paths.deferred_work - if not ledger.is_file(): - return # nothing to revert - if self._ledger_is_gits_to_restore(task): + 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 (and, for a sweep bundle, its `status: done` closes) 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 - ledger.unlink(missing_ok=True) + 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 any call site. Between a 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 here would + convert a correct recovery into a hands-off one.""" + 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 diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 2c53e074..0bf93081 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -160,20 +160,24 @@ 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 - # was the deferred-work ledger on disk when this attempt's baseline was captured? - # A plain `Path.is_file()`, never a git query — and that is the whole point. The - # ledger is commonly GITIGNORED (it lives under `output_folder`, which many - # projects ignore; `init` does not add that line but plenty of repos do, this one - # included), and every git-side signal here is ignore-blind: `baseline_untracked` - # and `verify.untracked_files` both come from `git ls-files --others - # --exclude-standard`. So in exactly the configuration where the question matters, - # git cannot answer it — an ignored ledger reads as BOTH "absent at the baseline" - # and "not created by this attempt". Read only by the crash-replay revert - # (`_drop_ledger_created_since_baseline`), where False is the ONLY value that - # authorizes deleting the ledger — so a task persisted before this field existed - # must rehydrate to None, never False (see from_dict). Scoped to the `_dev_phase` - # ENTRY, so attempts 2..N share it, exactly like baseline_commit. - baseline_ledger_present: bool | 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 spec_file: str | None = None commit_sha: str | None = None defer_reason: str | None = None @@ -266,7 +270,8 @@ def to_dict(self) -> dict[str, Any]: "followup_review_recommended": self.followup_review_recommended, "baseline_commit": self.baseline_commit, "baseline_untracked": self.baseline_untracked, - "baseline_ledger_present": self.baseline_ledger_present, + "pre_harvest_ledger": self.pre_harvest_ledger, + "pre_harvest_ledger_captured": self.pre_harvest_ledger_captured, "spec_file": self._serialized_spec_file(), "commit_sha": self.commit_sha, "defer_reason": self.defer_reason, @@ -314,18 +319,20 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": if d.get("baseline_untracked") is not None else None ), - # `is not None`, NOT the `bool(d.get(k, False))` idiom the neighbours use: - # a missing key has to stay None. Collapsing it to False would tell the - # crash-replay revert "this attempt created the ledger" for every task - # persisted before the field existed, and it would delete an operator's - # ledger on the first replay after upgrade. The explicit None test also - # keeps a persisted literal `false` as False, where a truthiness test - # would silently promote it back to "unknown". - baseline_ledger_present=( - bool(d["baseline_ledger_present"]) - if d.get("baseline_ledger_present") 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)), spec_file=d.get("spec_file"), commit_sha=d.get("commit_sha"), defer_reason=d.get("defer_reason"), diff --git a/tests/test_engine.py b/tests/test_engine.py index 3fba6477..f01724b3 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5883,14 +5883,11 @@ def crashing_emit(stage, *args, **kwargs): def test_harvest_untracked_ledger_reverted_across_a_crash_replay(project): - """The in-process snapshot is a local and dies with the host. 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. - - `task.baseline_untracked` survives the crash and is deliberately not - re-captured on a replay, so it still answers the question that matters: this - ledger was absent at the attempt baseline, is untracked now, therefore the dead - attempt created it — unlink.""" + """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)) @@ -5920,14 +5917,12 @@ def test_harvest_untracked_ledger_reverted_across_a_crash_replay(project): def test_harvest_tracked_ledger_reverted_across_a_crash_replay(project): - """The leg the finding understates: 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 recovery therefore keeps its hands off a tracked ledger, and the - pre-existing committed entry has to survive to prove it reverted rather than - truncated.""" + """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) @@ -5960,16 +5955,15 @@ def test_harvest_tracked_ledger_reverted_across_a_crash_replay(project): assert [e.title for e in _ledger_entries(project)] == ["Pre-existing"] -def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): - """The third state, and the one that must NOT be reverted: a ledger the - operator already had on disk but never committed. It is untracked at the - rollback exactly like a harvest-created one, so the `baseline_untracked` shield - is the ONLY thing separating them — without it the recovery deletes real - operator content that no reset would have touched and no commit can restore. - - The harvested entry riding along is the acknowledged cost: its pre-harvest text - is genuinely unrecoverable here, so the revert declines rather than guesses, and - the harvest's fingerprint dedup keeps the re-harvest quiet.""" +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) @@ -5980,12 +5974,13 @@ def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): 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 — - # exactly what `_drop_ledger_created_since_baseline` uses `.as_posix()` for. + # 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) @@ -5995,6 +5990,7 @@ def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): ) _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 @@ -6002,20 +5998,24 @@ def test_crash_replay_never_unlinks_a_ledger_untracked_at_the_baseline(project): assert resumed.run().done == 1 titles = [e.title for e in _ledger_entries(project)] - assert "Operator's own note" in titles # the whole point - assert HARVEST_A["summary"] in titles # documented, not accidental + 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. - `baseline_untracked` and `verify.untracked_files` both come from + 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 - BOTH. Classifying on those two alone drops it into the "neither ⇒ 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. + 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 @@ -6050,17 +6050,14 @@ def test_crash_replay_drops_a_gitignored_ledger_the_harvest_created(project): assert not project.deferred_work.exists() -def test_crash_replay_never_unlinks_a_gitignored_ledger_present_at_the_baseline(project): - """Regression guard, not a bug repro — it passes on the unfixed build, and it is - here to fail against the WRONG fix. - - Teaching the classifier to see ignored files without also fixing the baseline - record would delete this ledger: an ignored ledger the operator already had is - absent from `baseline_untracked` exactly like a harvest-created one, so the two - are indistinguishable from git alone. That is why the baseline record is a plain - `Path.is_file()` and not a widened git probe. Do not delete this as redundant with - the untracked-at-the-baseline sibling: that one is shielded by - `baseline_untracked`, and this one is invisible to it.""" +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) @@ -6072,6 +6069,7 @@ def test_crash_replay_never_unlinks_a_gitignored_ledger_present_at_the_baseline( 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. @@ -6084,6 +6082,7 @@ def test_crash_replay_never_unlinks_a_gitignored_ledger_present_at_the_baseline( ) _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 @@ -6091,19 +6090,21 @@ def test_crash_replay_never_unlinks_a_gitignored_ledger_present_at_the_baseline( assert resumed.run().done == 1 titles = [e.title for e in _ledger_entries(project)] - assert "Operator's own note" in titles # the whole point - assert HARVEST_A["summary"] in titles # documented cost, not an accident + 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. - "Absent at the baseline" is a creation for an untracked or ignored ledger, but - only a modification for this one — the harvest re-creates it, `reset --hard` + 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 the guard - the fix for the gitignored case would trade a stale entry for lost content.""" + 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) @@ -6141,7 +6142,10 @@ 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.""" + 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) @@ -6176,16 +6180,19 @@ def test_restore_ledger_keeps_a_tracked_ledger_the_reset_restored(project): assert project.deferred_work.read_text(encoding="utf-8") == before -def test_crash_replay_leaves_the_ledger_alone_on_a_pre_upgrade_task(project): - """A task persisted before `baseline_ledger_present` existed rehydrates to None, - and None must not be read as "absent at the baseline". +def test_crash_replay_leaves_the_ledger_alone_when_no_snapshot_was_persisted(project): + """A replay that finds nothing armed must keep its hands off entirely. + + 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, and both readings of a guess are 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 harvest surviving is the honest cost of + the one window that remains, and the skip is journalled rather than silent. - Released 0.9.1 shipped the harvest with no revert at all, so hands-off is exactly - what that run would have done — where guessing False would delete an operator's - ledger on the first crash-replay after upgrade. Deliberately the plain-untracked - layout, so the None path is isolated from the ignore path. 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.""" + 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 @@ -6202,17 +6209,279 @@ def test_crash_replay_leaves_the_ledger_alone_on_a_pre_upgrade_task(project): project, engine, [dev_effect(project, "1-1-a", followup_review=False)], policy=pol ) doc = resumed.state.tasks["1-1-a"].to_dict() - del doc["baseline_ledger_present"] # state.json as released 0.9.1 wrote it + 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 harvest survives — the acknowledged cost of refusing to guess — and the - # skip is journalled rather than silent, so it is not mistaken for a no-op assert [e.title for e in _ledger_entries(project)] == [HARVEST_A["summary"]] - assert "ledger-baseline-unknown" in [e["kind"] for e in resumed.journal.entries()] + assert "ledger-snapshot-missing" 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, and the replay correctly finds nothing.""" + 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" in kinds # ...and it found nothing armed + 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_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. diff --git a/tests/test_model.py b/tests/test_model.py index ab78502d..b020a155 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -160,23 +160,41 @@ def test_sentinel_kind_defaults_empty_for_legacy_state(): assert StoryTask.from_dict(doc).sentinel_kind == "" -def test_baseline_ledger_present_round_trips(): - """All three states, and the False leg is not decorative: a truthiness - deserializer (`bool(d.get(k))`) turns a persisted `false` back into "unknown", - which silently disables the crash-replay revert it exists to drive.""" - for value in (True, False, None): - task = StoryTask(story_key="1-1-a", epic=1, baseline_ledger_present=value) - assert StoryTask.from_dict(task.to_dict()).baseline_ledger_present is value - - -def test_baseline_ledger_present_defaults_none_for_legacy_state(): - """None, NOT False — the one place the neighbours' `bool(d.get(k, False))` idiom - is wrong. False is the only value that authorizes deleting the ledger, so a task - persisted before this field existed would have its ledger unlinked on the first - crash-replay after upgrade.""" +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["baseline_ledger_present"] # state.json from before the field existed - assert StoryTask.from_dict(doc).baseline_ledger_present is None + 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_restore_patch_round_trips(): diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 1020ca42..32f5748c 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -475,6 +475,68 @@ def test_generic_skill_bundle_orchestrator_closes_ledger(project): assert "sweep-bundle-closed" in kinds +def test_bundle_ledger_close_reverted_on_non_fixable_retry(project): + """The orchestrator closes a bundle's dw ids in `_post_dev_state_sync`, which + runs BEFORE the artifact gate that can still send the attempt back — so a close + can outlive the code it claims to resolve. It must not. + + A close is the more expensive of the two engine-side ledger writes to leave + behind: `deferredwork.open_ids` only ever re-bundles `open` entries, 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` + reverts the close by itself and the restore is unambiguously the only thing that + could put it back — which is exactly what a snapshot taken after the sync does. + + This is the test that discriminates the snapshot's POSITION. Move the arm in + `_dev_phase` back below `_post_dev_state_sync` and this reddens; nothing in + `test_engine.py`'s ledger family can see the difference, because there the + ledger is only ever touched by the harvest.""" + 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), + # closes DW-1 off `task.dw_ids`, then fails the gate on the CLAIM: the + # close fires either way, so the attempt is discarded with the ledger + # already rewritten + 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: closed, then non-fixably retried and rolled back + assert "sweep-bundle-closed" in {e["kind"] for e in engine.journal.entries()} + 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) + + # attempt 2 opens on a ledger whose close went with the code it described … + 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_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 From 8478394b90377d6bb4deff25bee90b2f863f7320 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 17:23:32 -0700 Subject: [PATCH 28/41] fix(worktree): gate the skills seed on walk parity with the copier (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worktree seed gate asked for `SKILL.md` plus the two DEV_PRIMITIVE_MARKERS — 3 of the 12 files a real `bmad-build-auto` install carries. The other nine were not inert: since BMAD-METHOD #2601 `workflow.md` is the renderer's entry document and names its step files as `[[bmad-snapshot:…]]` sources, so a file the seed dropped makes `render_skill.py` print `HALT:` and the session Stop having written nothing — on every story, because the seed reads the same repo every time. That is the environment fault this gate exists to catch. `_absent_skill_files` now walks the repo's skill dir and reports every rel the worktree lacks. It walks it with `_walk_traversable_files`, the SAME generator `_merge_traversable` now copies from, not an rglob mirror of it: rglob does not descend a symlinked sub-directory while `iterdir()` + `is_dir()` does, so a `review-prompts/` pointed at a shared install is dropped by the copy and would be invisible to a mirrored check. This reverses the rationale `_absent_skill_files` carried, which rejected parity because it "would halt the whole backlog over a repo-only README.md". 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 is delivered like anything else and parity stays silent. What parity can report is exactly the set of files the seed FAILED to deliver, and every one of those has an operator remedy. `test_..._ignores_a_file_no_layer_requires` pinned the reversed design by building its pair by hand; it is rewritten to drive the real `provision_worktree` and assert the README is DELIVERED. DEV_PRIMITIVE_MARKERS is untouched — it is still the shim detector and still the preflight's requirement. Ablation A14 (un-sharing the walk) is structurally untestable: an un-shared copier recursion is byte-identical to the walker today, so no test can observe the difference until one half is edited — at which point A2 catches the gate half. Sharing is a structural guarantee, not a tested one. To be stated in the PR rather than papered over. Fixture note: `install_build_auto_skill(renderer_stub=True)` and `_symlink_skill_tree(renderer_stub=True)` now also write `workflow.md`, so a #2601 stub and the entry it composes from stay one era. Inert until the renderer-sources preflight lands. --- CHANGELOG.md | 11 ++- src/bmad_loop/engine.py | 12 +-- src/bmad_loop/install.py | 158 ++++++++++++++++++++++-------- tests/conftest.py | 19 +++- tests/test_engine_worktree.py | 179 ++++++++++++++++++++++++++++++++-- tests/test_install.py | 88 +++++++++++++---- 6 files changed, 390 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5190a0..d93b0eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,10 +65,13 @@ editing. 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 compares the same required files the run-start preflight does — naming the - one that is missing instead of the directory. Containment is per file too, so a single skill - file symlinked out of the repo is reported like a symlinked tree instead of being followed - silently. A skill tree that is committed (symlink and all) + completeness check walks the repo's skill directory exactly the way the copy walks it, + reporting every file the seed could not deliver and naming it instead of the directory. A + fixed list of required files would have covered three of the twelve 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.6.5; found while reviewing this release. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 59139739..ac3dfadc 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -640,8 +640,8 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> "worktree-seed-skipped", story_key=task.story_key, entries=skipped_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 a required file - # inside one — stalls the session whether that SKILL.md renders or is inline, + # 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 @@ -658,10 +658,10 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> 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"required file. The usual cause is a skill directory, or one file " - f"inside one, symlinked to a shared BMad install outside the repo, " - f"which worktree seeding cannot follow; a checked-out symlink whose " - f"target does not exist on this machine reads the same way" + 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) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 8c141843..c03239af 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -19,7 +19,7 @@ import json 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, PurePosixPath @@ -86,6 +86,12 @@ # 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 — two markers are 3 of the 12 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") @@ -804,17 +810,63 @@ def _copy_traversable(src, dst: Path) -> None: dst.write_bytes(src.read_bytes()) +def _walk_traversable_files(src, rel: str = "") -> Iterator[tuple[str, object]]: + """Yield ``(rel, traversable)`` for every non-directory entry under ``src``. + + 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 — 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. Callers + that might be handed a path with no dir behind it must say so themselves — + :func:`base_skills_seed_incomplete` behind its ``SKILL.md`` precondition and the + ``BASE_SKILLS`` copy loop behind ``src_root.is_dir()`` both do. Ablating either + guard turns that pair into a bogus empty rel, which is what makes them + load-bearing well beyond the one case each was written for. + + Lazy by construction and it must stay that way: an ``iterdir()`` + ``PermissionError`` surfaces mid-walk where it does today rather than at the + first ``next()``. + """ + if src.is_dir(): + for child in sorted(src.iterdir(), key=lambda entry: entry.name): + yield from _walk_traversable_files(child, f"{rel}/{child.name}" if rel else child.name) + return + yield rel, src + + def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = None) -> None: - """Recursively copy a resource tree into a worktree, copy-when-absent per FILE. + """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 ``.iterdir()`` rather than ``rglob`` so a zip-imported wheel - works, and creates parents lazily at the leaf so an empty source dir is not - recreated. + 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 @@ -822,16 +874,14 @@ def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = 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. """ - if src.is_dir(): - for child in src.iterdir(): - _merge_traversable(child, dst / child.name, worktree, repo_root) - return - if repo_root is not None and not Path(str(src)).resolve().is_relative_to(repo_root): - return - if not dst.resolve().is_relative_to(worktree) or dst.exists(): - return - dst.parent.mkdir(parents=True, exist_ok=True) - _copy_traversable(src, dst) + for rel, child in _walk_traversable_files(src): + 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 target.exists(): + continue + target.parent.mkdir(parents=True, exist_ok=True) + _copy_traversable(child, target) def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None: @@ -975,31 +1025,48 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: ) -def _absent_skill_files( - repo_skill: Path, worktree_skill: Path, markers: Sequence[str] -) -> list[str]: - """The REQUIRED files of one skill dir the repo has and the worktree does not. - - The required surface is the one :func:`missing_base_skills` already asserts at - run start — ``SKILL.md`` plus the skill's :data:`BASE_SKILLS` markers — so the - two layers demand the same set and a worktree the preflight calls healthy is - never refused here. Deliberately *not* rglob parity with the repo's dir: that is - right for ``_bmad/scripts/`` (see :func:`_bmad_scripts_seed_incomplete`) precisely - because it has no named surface, but a skill dir does, and parity would halt the - whole backlog over a repo-only ``README.md`` no session reads, with no remedy - available to the operator. The bounded blind spot is an undeliverable - *non-required* file, reported by neither layer; the remedy for one that turns out - to matter is to add it to the marker tuple, where both layers pick it up at once. - - Each leg is conjuncted on the REPO's copy having the file, so a project whose - install predates a marker is not asked for something no copy could have delivered. - ``is_file`` rather than ``exists`` throughout: a destination occupied by a - directory is not the file the skill needs. +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 12 files a real ``bmad-build-auto`` install carries, and the other nine 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. """ return [ rel - for rel in ("SKILL.md", *markers) - if (repo_skill / rel).is_file() and not (worktree_skill / rel).is_file() + for rel, _ in _walk_traversable_files(repo_skill) + if not worktree_skill.joinpath(*rel.split("/")).is_file() ] @@ -1038,8 +1105,12 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence 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 precondition is - ``SKILL.md`` rather than ``is_dir()`` for the same reason in the other direction — + 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* @@ -1082,7 +1153,7 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence unused_era = {DEV_PRIMITIVE_NEW, DEV_PRIMITIVE_LEGACY} - { dev_primitive_or_default(repo_root, tree) } - for skill, markers in BASE_SKILLS.items(): + for skill in BASE_SKILLS: if skill in unused_era: continue repo_skill = repo_root / tree / skill @@ -1093,8 +1164,7 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence missing.append(f"{tree}/{skill}") continue missing += [ - f"{tree}/{skill}/{rel}" - for rel in _absent_skill_files(repo_skill, worktree_skill, markers) + f"{tree}/{skill}/{rel}" for rel in _absent_skill_files(repo_skill, worktree_skill) ] return missing @@ -1327,7 +1397,9 @@ def provision_worktree( # `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. The per-file skips are deliberately NOT reported: the + # 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 diff --git a/tests/conftest.py b/tests/conftest.py index c9469123..1e11f048 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,6 +307,18 @@ def install_base_skills(paths: ProjectPaths, trees=(".claude/skills", ".agents/s "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( @@ -324,7 +336,10 @@ def install_build_auto_skill( 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. Returns the skill dir.""" + 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 @@ -332,6 +347,8 @@ def install_build_auto_skill( (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: diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 8bb52b5a..97d52135 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -831,7 +831,7 @@ def _symlink_skill_tree( ``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 + from conftest import RENDERER_STUB_SKILL_MD, RENDERER_WORKFLOW_MD from bmad_loop.install import DEV_PRIMITIVE_MARKERS, DEV_PRIMITIVE_NEW, REVIEW_HUNTER_SKILLS @@ -844,6 +844,10 @@ def _symlink_skill_tree( (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) @@ -862,11 +866,16 @@ def _symlink_skill_file( :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 required file, - 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. + 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. @@ -879,6 +888,36 @@ def _symlink_skill_file( 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 @@ -1129,6 +1168,134 @@ def never(spec): 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 twelve 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 diff --git a/tests/test_install.py b/tests/test_install.py index d6e3f242..ceeb5580 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -721,8 +721,12 @@ 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 asks per required FILE behind a `SKILL.md` - precondition, never how the worktree's copy of one got there. + `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): @@ -819,12 +823,15 @@ def test_base_skills_seed_incomplete_asks_is_file_not_exists(tmp_path): def test_base_skills_seed_incomplete_cannot_demand_what_the_repo_lacks(tmp_path): - """Every required file is conjuncted on the REPO's copy having it, so an install - predating a marker 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.""" + """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() @@ -832,22 +839,69 @@ def test_base_skills_seed_incomplete_cannot_demand_what_the_repo_lacks(tmp_path) assert base_skills_seed_incomplete(wt, repo, [tree]) == [] -def test_base_skills_seed_incomplete_ignores_a_file_no_layer_requires(tmp_path): - """What rejects rglob-parity-with-the-repo as this gate's surface. Parity is right - for `_bmad/scripts/`, which has no named surface, but a skill dir has one — and it - is the surface the preflight already asserts, so the two layers demand the same set - and a worktree the preflight called healthy is never refused here. Parity instead - would halt the whole backlog over a repo-only README.md no session reads, with no - remedy available to the operator.""" - wt, repo, tree = _seed_pair(tmp_path, BASE_SKILLS) - repo_skill = repo / tree / DEV_PRIMITIVE_NEW +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 From 29ea28f9ff498827bc5ad012b5f5e947ce0dae89 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 18:07:13 -0700 Subject: [PATCH 29/41] fix(sweep): close a bundle's ledger ids only once its attempt is accepted (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator marks a bundle's deferred-work ids `status: done` itself, and did so in `_post_dev_state_sync` — above the artifact gate that can still discard the attempt. A close is the most expensive engine-side ledger write to leave behind: `deferredwork.open_ids` re-bundles only `open` entries and the module has no reopen primitive, so a `done` id whose code was rolled back is invisible to every future sweep. The work is lost, not merely mis-recorded. On the non-fixable RETRY leg `_dev_phase`'s pre-harvest ledger snapshot reverted it. On the DEFER terminus nothing did, and no snapshot in `_dev_phase` could have: `_defer` takes its OWN snapshot after the close, resets, and writes those bytes back on purpose so review-found deferrals survive a defer. Confirmed data loss, and PRE-EXISTING — it reproduces identically at 64c4f2c^. New base seam `_post_dev_accepted_sync`, called on the PROCEED branch of `_dev_phase` and a no-op on the base path; `SweepEngine` moves the close there and overrides `_post_dev_state_sync` to a no-op (a bundle has no sprint row). The shared pre-gate call site could NOT simply move: `verify_dev` reads the sprint board that `_post_dev_state_sync` writes, so relocating it would fail every story's own gate. Gated on the DECISION being PROCEED rather than on `outcome.ok`, which excludes two further discards for free — a CRITICAL escalation preempts even a passing outcome, and a failing `[verify] commands` run replaces the outcome after the gate. One deliberate consequence: the close no longer contributes to the gate's proof-of-work diff (the ledger is not in `verify_dev_exclude_relpaths`), so a bundle session that changed no code now fails that gate instead of passing on the orchestrator's own bookkeeping. Tests: `..._withheld_when_a_completed_attempt_defers` is the discriminator the suite could not previously express — it differs from the retry test by one thing, a second COMPLETED attempt instead of a corpse, which is what reaches `_defer` with the ledger already rewritten. `..._withheld_when_a_critical_escalation_preempts_the_gate` is the only scenario separating PROCEED from `outcome.ok`. The retry test is renamed and its docstring rewritten: it no longer discriminates the snapshot's position, since the snapshot no longer guards any close. Ablations, each against the WHOLE suite (5 sandbox-only failures subtracted — the sandbox omits scripts/ and examples/; identical in every row, incl. the control): - whole-leg revert (close back above the gate) → the 3 withheld tests + the direct-call unreadable-spec test. Happy-path close tests stay GREEN, correctly: the close still fires, just earlier. - delete the call site → 6 "a close DOES happen" tests. The withheld tests stay GREEN. `_verify_review`'s reclose does NOT mask this, because those tests assert the `sweep-bundle-closed` kind specifically, not the end state. - below the gate but ungated by the decision → exactly the 3 withheld tests. - gate on `outcome.ok` instead of PROCEED → the CRITICAL test ALONE; retry and defer are insensitive because their outcomes are not ok. No `test_engine.py` ledger-family test moves under any row, confirming the change is confined to the sweep path. Prose corrected rather than left stale: the arm comment and `_harvest_spec_deferrals`/`_restore_persisted_ledger` no longer cite the close as something the snapshot guards, and `CHANGELOG` :142-145 is rewritten — it does not become true unscoped, because the mechanism it states (the snapshot reverts the closes) is no longer how they are protected. `_finish_inflight_bundles`' "a recovered bundle that defers or escalates keeps its ids open" was false as written and is now accurate: true for escalate and for a DEV-leg defer, explicitly NOT true for a REVIEW-leg defer, where the ids are already `done` and `_defer`'s indiscriminate restore rides them over the reset. That residual is pre-existing and out of this change's scope. --- CHANGELOG.md | 22 ++++-- src/bmad_loop/engine.py | 66 +++++++++++++----- src/bmad_loop/sweep.py | 58 ++++++++++++++-- tests/test_sweep.py | 147 +++++++++++++++++++++++++++++++++------- 4 files changed, 243 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d93b0eb6..1eff4419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,10 +140,9 @@ editing. 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 the orchestrator's own ledger writes too, so a sweep bundle's `status: done` closes - revert along with the code they claimed to resolve — a surviving close is worse than a - surviving finding, since `open_ids` only ever re-bundles open entries and no later sweep would - look at that id again. And it is persisted with the attempt rather than held in a local, so a + 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 @@ -157,6 +156,21 @@ editing. 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. +- **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 there is no reopen primitive, 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; no dev-phase leg + needs a revert any more. 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. + ## [0.9.0] — 2026-07-21 ### Added diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index ac3dfadc..bed9dd14 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1880,14 +1880,18 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # (`_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 — `_post_dev_state_sync`, where the sweep engine closes - # the bundle's entries `status: done`, as well as - # `_harvest_spec_deferrals`. Both of those describe work the rollback - # is about to discard, and a close is the more expensive of the two to - # get wrong: `deferredwork.open_ids` only ever re-bundles `open` - # entries, so a close that outlives its code hides the work from every - # future sweep. Snapshotting after the sync would write that close - # back on top of a `reset --hard` that had just correctly reverted it. + # 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 revert + # of it is needed at all. See that method for why a snapshot could + # never have covered it on the DEFER leg (#405). # # NOT on a replayed attempt: the host died somewhere between the dead # process's writes and its rollback, so what is on disk now is already @@ -1954,6 +1958,10 @@ 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 @@ -2602,9 +2610,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") @@ -2623,6 +2636,27 @@ 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). A single accepted-only + call site needs no undo on any leg. + + 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. @@ -2665,10 +2699,8 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | None) -> 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 — ahead of `_post_dev_state_sync` too, so a sweep bundle's - `status: done` closes revert with the code they claim to resolve — and - restores it around `_rollback_or_pause`; that restore, not the reset, is - what makes the revert unconditional. The snapshot is persisted with the + 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 @@ -3465,8 +3497,8 @@ def _restore_persisted_ledger(self, task: StoryTask, *, replayed: bool) -> None: 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 (and, for a sweep bundle, its `status: done` closes) behind for the - successful retry to commit. `_resumable_session` replays the recorded result + 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. diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index f3913e5a..f4cf443d 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -492,9 +492,23 @@ 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 escalates keeps its ids open, and so does one that + defers 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. `failed_ids` then drops the fresh plan's overlapping + bundle. + + A bundle that gets past dev and then defers in REVIEW does NOT keep its ids + open, and the wording above used to claim otherwise. By that point they are + `done` — the accepted dev attempt closed them, and `_verify_review` re-closes + ahead of `verify_review_bundle` besides — and `_defer` writes its own ledger + snapshot back over its `reset --hard`, deliberately, so review-found + deferrals survive a defer. The restore is indiscriminate, so those closes ride + it too and name code the reset just discarded. `failed_ids` still covers the + current run; nothing covers a later one, because `open_ids` re-bundles only + `open` entries and `deferredwork` has no reopen primitive (#405).""" recovered = 0 for task in list(self.state.tasks.values()): if task.terminal or not BUNDLE_KEY_RE.match(task.story_key): @@ -1159,7 +1173,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 @@ -1197,11 +1211,43 @@ 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 has no + reopen primitive, so a `done` id whose code was discarded is invisible to + every future sweep — 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 reads the ledger: + `verify_dev_bundle` gates on the spec path, `_verify_shared_gates` and an + in-memory `dw_ids` cross-check, and `[verify] commands` is operator shell. + `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") diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 32f5748c..305b6a70 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -436,8 +436,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,22 +476,21 @@ def test_generic_skill_bundle_orchestrator_closes_ledger(project): assert "sweep-bundle-closed" in kinds -def test_bundle_ledger_close_reverted_on_non_fixable_retry(project): - """The orchestrator closes a bundle's dw ids in `_post_dev_state_sync`, which - runs BEFORE the artifact gate that can still send the attempt back — so a close - can outlive the code it claims to resolve. It must not. +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 more expensive of the two engine-side ledger writes to leave - behind: `deferredwork.open_ids` only ever re-bundles `open` entries, 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` - reverts the close by itself and the restore is unambiguously the only thing that - could put it back — which is exactly what a snapshot taken after the sync does. + 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. - This is the test that discriminates the snapshot's POSITION. Move the arm in - `_dev_phase` back below `_post_dev_state_sync` and this reddens; nothing in - `test_engine.py`'s ledger family can see the difference, because there the - ledger is only ever touched by the harvest.""" + 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"}] @@ -513,9 +513,9 @@ def capture_then_die(spec): project, [ triage_effect(plan), - # closes DW-1 off `task.dw_ids`, then fails the gate on the CLAIM: the - # close fires either way, so the attempt is discarded with the ledger - # already rewritten + # 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, ], @@ -523,13 +523,13 @@ def capture_then_die(spec): ) summary = engine.run() - # the scenario really is: closed, then non-fixably retried and rolled back - assert "sweep-bundle-closed" in {e["kind"] for e in engine.journal.entries()} + # 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 whose close went with the code it described … + # 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 @@ -537,6 +537,107 @@ def capture_then_die(spec): 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() + assert "sweep-bundle-closed" not in {e["kind"] for e in engine.journal.entries()} + + +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_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 @@ -556,7 +657,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" From a8ab809b01fdd6487a81b7b5a5c2d0eda4e7a27e Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 18:08:18 -0700 Subject: [PATCH 30/41] fix(engine): persist the ledger disarm on the PROCEED path (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_disarm_ledger_snapshot`'s docstring claimed it is "deliberately NOT followed by a `_save()`, at any call site, because a death in that window replays *that same attempt*". That rationale is load-bearing at exactly ONE of the four call sites, and reading it as universal left a real hole. - Site 0 (fresh entry): a `_save()` deliberately DOES follow on the veto path, and a death before it leaves the phase `pending`, so the resume RESTARTS the story rather than replaying the attempt. - Site 1 (PROCEED): the hole. `verify_dev` has already set `task.spec_file` and the phase is `DEV_VERIFY`, so a hard kill here resumes through `_finish_inflight`'s FIRST branch → `_resume_after_dev_verify` → `_review_and_commit`. `_dev_phase` is never re-entered and `_resumable_session` is never consulted, so nothing ever consumes the snapshot — a full copy of the ledger rides a terminal task in state.json for the rest of the run. - Site 2 (RETRY): where the rationale holds. The same kill replays that attempt, which still wants the snapshot, so the stale-looking persisted value is correct. - Site 3 (DEFER/escalate): `_defer`/`_escalate` save immediately below. Adds `self._save()` at site 1 only, and scopes the docstring to the retry site instead of stating it universally. Bounded severity — state.json residue on a hard kill, and nothing reads the field on a terminal task — but the universal CLAIM was the real defect, since it is the reason a reader would not add the save. Test: `test_the_proceed_disarm_is_persisted_before_the_review_leg` reads state.json off disk at the `post_dev_phase` emit, the first moment after the disarm. It has to: `run()`'s `finally: self._save()` re-persists the disarmed fields after any crash an in-process fixture can stage, so the pre-existing `test_a_finished_story_carries_no_ledger_copy` stays GREEN with this `_save()` deleted. Same technique, and same reason, as `test_the_pre_harvest_snapshot_is_persisted_before_the_harvest_runs` uses for the arm. Ablation against the WHOLE suite (delete the `_save()`): the new test alone reddens, and `..._carries_no_ledger_copy` stays green — which is the measurement showing the new test is not redundant with it rather than an assertion that it isn't. --- CHANGELOG.md | 6 ++++- src/bmad_loop/engine.py | 33 ++++++++++++++++++++++----- tests/test_engine.py | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eff4419..c50bde34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,7 +152,11 @@ editing. `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`. A defer still + 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. 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. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index bed9dd14..0289624a 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1966,7 +1966,18 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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 @@ -3541,11 +3552,23 @@ def _disarm_ledger_snapshot(self, task: StoryTask) -> None: 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 any call site. Between a 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 here would - convert a correct recovery into a hands-off one.""" + 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 diff --git a/tests/test_engine.py b/tests/test_engine.py index f01724b3..13902050 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6359,6 +6359,55 @@ def test_a_finished_story_carries_no_ledger_copy(project): 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. From d54d595dc9f872dab6dbc519d5aa19b5433add41 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 18:21:14 -0700 Subject: [PATCH 31/41] fix(sweep): re-open a bundle's ledger ids when a review-leg defer discards them (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit stops a DISCARDED dev attempt from closing anything. The review leg is a different shape and it could not cover it: a bundle accepted at dev has its ids closed correctly, and they have to be on disk because `verify_review_bundle` gates on them. Only the later review failure makes them wrong — and then `_defer` writes its own post-close ledger snapshot back over the `reset --hard` that had reverted them. 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 the closes ride it too and name code the reset discarded — `status: done` with nothing behind it, invisible to `deferredwork.open_ids`, which re-bundles only `open` entries. Verified by an independent probe on FOUR routes into that `_defer`, reproduced on a pristine export of 8478394 as well as on this branch: review budget exhausted and not refileable, the repair phase exhausted after a clean review, the budget rescue's own `_verify_review` red, and `review.enabled = false` failing at its gate. Escalate and `rollback_on_failure = off` are unaffected — neither resets. New base seam `_reopen_ledger_after_defer`, called inside `_defer`'s `baseline_commit` branch after the restore; no-op on the base path. It is unreached when `_rollback_or_pause` raises (stop-and-wait keeps the tree) and the isolated branch returns before it (those closes live in the unit worktree, dropped unmerged). `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 passed, so a close written by an earlier sweep, by the legacy path where the session edits the ledger itself, or by a human is never revoked — the ledger has no field that could record who reopened what after the fact. The round trip is byte-exact, because `mark_done` writes its resolution on the line directly below the status and this removes exactly that line. Ablations, each against the WHOLE suite (5 sandbox-only failures subtracted): - delete the `_defer` call site → both review-leg integration tests, nothing else. - delete SweepEngine's override → identical set, so the seam is not accidentally satisfied by the base. - drop the note-match conjunct from `mark_open` → `..._refuses_a_close_it_did_not_ write` alone. - drop the `entry.open` conjunct → `..._refuses_an_entry_that_is_still_open` alone. That guard is NOT redundant with the note match: an entry the inner session appended can be open and still carry a `resolution:` line, and without the guard its note is silently deleted. Rows that did not redden, read: `test_defer_keeps_the_harvested_entry_after_rollback` stays green under every row, so the harvest-preservation contract `_defer`'s restore exists for is untouched; `test_sweep_worktree_bundle_merges_to_target` likewise, so the isolated path is unaffected. `_finish_inflight_bundles`' "a recovered bundle that defers or escalates keeps its ids open" is now true on both legs — by two different mechanisms, and it held on neither before this pair of commits. --- CHANGELOG.md | 17 ++++++ src/bmad_loop/deferredwork.py | 36 +++++++++++++ src/bmad_loop/engine.py | 32 ++++++++++++ src/bmad_loop/sweep.py | 45 +++++++++++----- tests/test_deferredwork.py | 68 ++++++++++++++++++++++++ tests/test_sweep.py | 97 +++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c50bde34..69864b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,23 @@ editing. 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 all four routes into that defer: review budget + exhausted, the repair phase exhausted after a clean review, the budget rescue's own verify + failing, and `review.enabled = false` failing at its 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 is byte-exact. 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). + ## [0.9.0] — 2026-07-21 ### Added diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 28655973..025bd348 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -103,6 +103,42 @@ 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 <date>` 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 is byte-exact — `mark_done` writes its resolution on the line + directly after the status line, and this removes exactly that line — which is + what lets a caller undo a close without having snapshotted the file.""" + 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: + 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(): diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 0289624a..af41b884 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -3417,6 +3417,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, @@ -3426,6 +3430,34 @@ 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 only where a reset actually happened: inside the `baseline_commit` + branch, 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 diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index f4cf443d..ca74d092 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -494,21 +494,17 @@ def _finish_inflight_bundles(self) -> int: 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 escalates keeps its ids open, and so does one that - defers on the DEV leg: the close is withheld until the attempt is accepted + 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. `failed_ids` then drops the fresh plan's overlapping - bundle. - - A bundle that gets past dev and then defers in REVIEW does NOT keep its ids - open, and the wording above used to claim otherwise. By that point they are - `done` — the accepted dev attempt closed them, and `_verify_review` re-closes - ahead of `verify_review_bundle` besides — and `_defer` writes its own ledger - snapshot back over its `reset --hard`, deliberately, so review-found - deferrals survive a defer. The restore is indiscriminate, so those closes ride - it too and name code the reset just discarded. `failed_ids` still covers the - current run; nothing covers a later one, because `open_ids` re-bundles only - `open` entries and `deferredwork` has no reopen primitive (#405).""" + 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): @@ -1277,6 +1273,27 @@ 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. + + 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() diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 31ae46d6..282761ef 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,73 @@ 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_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") diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 305b6a70..226e1027 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -34,6 +34,7 @@ ScmPolicy, StageAdapterPolicy, SweepPolicy, + VerifyPolicy, ) from bmad_loop.sweep import DecisionPrompter, SweepEngine, validate_migration, validate_triage from bmad_loop.verify import worktree_clean @@ -638,6 +639,102 @@ def clean_but_critical(spec): 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): + """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. + + The verify command passes once and fails once, so it 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.""" + write_ledger(project, {"DW-1": "open"}) + plan = triage_result( + ["DW-1"], bundles=[{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}] + ) + once = "n=$(cat CNT 2>/dev/null || echo 0); n=$((n+1)); echo $n > CNT; test $n -lt 2" + 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=(once,)), + # 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 From 07918cd74f800dd23be6f71ca533319972f2f47d Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 18:55:35 -0700 Subject: [PATCH 32/41] fix(sweep): correct claims and portability found by adversarial review (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to an independent skeptic pass over 29ea28f..d54d595. Eight findings, one of them a real breakage on a lane that is not run locally. 1. **Windows CI breakage.** The review-disabled defer test used a POSIX shell counter (`$(...)`, `$((...))`, `test -lt`) as its verify command. `shell=True` on Windows is `cmd.exe /c`, which does not reject that string — it MIS-PARSES it into a constant exit code, so the command can no longer pass once and fail once and the test fails on windows-latest, which runs the full suite. Replaced with a `passes_once` conftest helper written for both shells, alongside the existing `_file_exists_cmd` family. It takes an explicit path: `run_verify_commands` passes no env, so the `$BMAD_LOOP_RUN_DIR` its neighbours use is unset there and the marker would latch at the filesystem root. Ablation: forcing the helper to a constant exit code — what cmd actually produces — reddens exactly that test. 2. **"No leg needs a revert" was false**, in three places. A blocking `post_dev_phase` workflow can defer an ACCEPTED attempt after the close, so the undo is load-bearing on a dev-phase leg too. The code was already right; the sentences were not. The CHANGELOG's "all four routes" was an undercount for the same reason. 3. **A coverage regression I introduced.** Rewriting the retry test removed the only test that pinned the pre-harvest arm's POSITION, while the comment still claimed the boundary was deliberate. Measured: sliding the arm below both syncs is green at HEAD and red at 8478394. The comment now says so — the boundary is defensive positioning for a future write, not an observable property. 4. **"Byte-exact" round trip was overstated.** `mark_done` rewrites the whole file through `write_text`, so a CRLF-stored ledger is normalised by the close and the reopen cannot put that back. Restated as character-for-character as `read_text` sees it, with the pre-existing normalisation named. 5. **"Nothing between the old position and this one reads the ledger" was false** — `_harvest_spec_deferrals` sits in that span and reads it twice. Effect is inert (its pre-scan matches the fingerprinted `origin:` across entries of every status, so a status flip cannot change what it files), but the enumeration read as exhaustive. Narrowed to "gates on", with the harvest named and its insensitivity explained. 6. **Three conjuncts no test could redden**, now pinned, each ablated to confirm it reddens exactly its own test: `mark_open`'s status-less-entry guard (its absence is an AttributeError raised from inside `_defer` — a crash instead of a deferral, and `parse_ledger` explicitly tolerates such entries); the `.strip()` on the note compare (a session-reformatted resolution line must still be recognised as our own close); and `_reopen_ledger_after_defer`'s `if reopened:` (pinned by asserting the dev-leg defer journals nothing — withholding the close is what fixes that leg, not undoing one). 7. `_reopen_ledger_after_defer` has no `_generic_dev()` guard, unlike both its siblings. Deliberate — they WRITE and must know which path owns the ledger, this one only undoes a note it recognises — and now said rather than left as an unexplained asymmetry. 8. "Runs only where a reset actually happened" was imprecise: `_rollback_or_pause` is a no-op on an already-clean tree. Not reachable with anything to undo (a written close makes the tree dirty), but reworded to what the code does. Suite 2873 collected (2872 passed, 1 skipped) from 2871; full trunk check clean. --- CHANGELOG.md | 12 +++++++----- src/bmad_loop/deferredwork.py | 14 +++++++++++--- src/bmad_loop/engine.py | 27 ++++++++++++++++++++------- src/bmad_loop/sweep.py | 19 +++++++++++++------ tests/conftest.py | 22 ++++++++++++++++++++++ tests/test_deferredwork.py | 31 +++++++++++++++++++++++++++++++ tests/test_sweep.py | 22 ++++++++++++++-------- 7 files changed, 118 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69864b04..d63fce97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,8 +170,8 @@ editing. 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; no dev-phase leg - needs a revert any more. One deliberate consequence: that write no longer counts toward the gate's + (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. @@ -182,13 +182,15 @@ editing. 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 all four routes into that defer: review budget + 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, and `review.enabled = false` failing at its gate. The defer now re-opens the ids it + 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 is byte-exact. Unchanged on 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). diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 025bd348..556cb745 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -118,9 +118,14 @@ def mark_open(path: Path, dw_id: str, note: str) -> bool: 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 is byte-exact — `mark_done` writes its resolution on the line - directly after the status line, and this removes exactly that line — which is - what lets a caller undo a close without having snapshotted the file.""" + 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") @@ -129,6 +134,9 @@ def mark_open(path: Path, dw_id: str, note: str) -> bool: 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: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index af41b884..33270c23 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1889,9 +1889,19 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # # 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 revert - # of it is needed at all. See that method for why a snapshot could - # never have covered it on the DEFER leg (#405). + # 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: the host died somewhere between the dead # process's writes and its rollback, so what is on disk now is already @@ -2657,8 +2667,11 @@ def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> 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). A single accepted-only - call site needs no undo on any leg. + 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 @@ -3449,8 +3462,8 @@ def _reopen_ledger_after_defer(self, task: StoryTask) -> None: — `verify_review_bundle` requires it — and only the later review failure makes it wrong (#405). - Runs only where a reset actually happened: inside the `baseline_commit` - branch, after the restore, and unreached when `_rollback_or_pause` raises — + 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. diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index ca74d092..baea3bca 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1232,12 +1232,16 @@ def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> 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 reads the ledger: - `verify_dev_bundle` gates on the spec path, `_verify_shared_gates` and an + 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. - `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. + `_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 @@ -1282,7 +1286,10 @@ def _reopen_ledger_after_defer(self, task: StoryTask) -> None: 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. + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 1e11f048..d2593461 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("/", "\\") diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 282761ef..b55c106b 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -169,6 +169,37 @@ def test_mark_open_refuses_an_entry_that_is_still_open(tmp_path): 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) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 226e1027..d499087f 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, @@ -589,7 +590,11 @@ def mismatching_attempt(): 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() - assert "sweep-bundle-closed" not in {e["kind"] for e in engine.journal.entries()} + 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): @@ -692,27 +697,28 @@ def test_bundle_ledger_close_reopened_when_the_review_leg_defers(project): assert worktree_clean(project.project) -def test_bundle_ledger_close_reopened_when_review_disabled_defers_at_the_gate(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. - The verify command passes once and fails once, so it 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.""" + `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"}] ) - once = "n=$(cat CNT 2>/dev/null || echo 0); n=$((n+1)); echo $n > CNT; test $n -lt 2" 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=(once,)), + 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), From 3de62706f26081b2d8c260835336d719a3096059 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 19:47:12 -0700 Subject: [PATCH 33/41] fix(install): gate a renderer stub on its own declared render sources (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2 Part 2. `missing_base_skills` asked a resolved renderer stub for its script unit and central config but never for the documents it composes FROM, so a truncated repo-side install passed `validate` and then HALTed every session — the marker pair answered for 3 of the 13 files a real `bmad-build-auto` carries. New `skills.dev-renderer-sources` (registered in `checks.VALIDATE_CHECKS`) asks two questions, both a deterministic `HALT:` upstream: `workflow.md` is a source, and every `[[bmad-snapshot:…]]` target is a source too. The source set and the token regex are byte-for-byte mirrors of `render_skill.py`'s own — the gate has no severity filter and no `--force`, so it asks what the renderer will SEE and only what the install DECLARES, never a fixed renderer-era file list that a reorganized upstream would trip. Deliberately mirrors the renderer's `rglob` rather than borrowing the copier's `_walk_traversable_files`, which descends symlinked sub-dirs the renderer never enumerates. Ablations: 19 rows + a control, each against the WHOLE suite. Part 1 (A1–A7, A15, A14) re-run unchanged; Part 2 A8–A13, A16, A17 each reddened exactly its predicted set, four of them with a sole witness. Both of CW's predicted GREENs confirmed: A1 leaves T3 green and A8 leaves every Part-1 row green. A14 (un-share the walk) reddens NOTHING — structurally untestable, to be stated in the PR description rather than papered over. A15 was run in both shapes; their union is exactly Phase 2's eight. CHANGELOG re-verified clause by clause against source, which turned up four false claims beyond the one this commit creates: the install carries THIRTEEN files, not twelve (fixed in all five copies); the defect is pre-existing since 0.7.0, not 0.6.5, which was never released; a missing render *script* cannot print `HALT:` — the stub's SKILL.md is what halts; and per-file naming is the else-branch only, a wholly-absent skill still reports the coarse rel. --- CHANGELOG.md | 36 ++--- src/bmad_loop/checks.py | 1 + src/bmad_loop/install.py | 118 +++++++++++++++- tests/test_cli.py | 22 +++ tests/test_engine_worktree.py | 2 +- tests/test_install.py | 245 +++++++++++++++++++++++++++++++++- 6 files changed, 400 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63fce97..05845863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,29 +66,35 @@ editing. 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 and naming it instead of the directory. A - fixed list of required files would have covered three of the twelve a real `bmad-build-auto` + 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.6.5; found - while reviewing this release. + 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).** Two new checks +- **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 — and `skills.dev-renderer-config`, when a stub resolved but `_bmad/config.toml` — the - renderer's one required config layer — is absent. Every route ends in a session that Stops - having written no spec; all three files are project-global, so every story after it does the - same. A missing script or config exits `HALT:`; a missing sibling raises `ModuleNotFoundError` - above the renderer's own guard and loses even that line. The sibling is required only when the - installed `render_skill.py` actually imports it, so a later renderer that inlines or renames - the helper 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 both under its "NOT runnable as-is" banner. A third check, + 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 a file the skill does not carry. 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 only what the install itself declares, 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. diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index fdf9f7e1..dbd388b9 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -62,6 +62,7 @@ "skills.base-shim", "skills.dev-renderer", "skills.dev-renderer-config", + "skills.dev-renderer-sources", "skills.customize-legacy", "skills.stories-dispatch", "skills.stories-dispatch-missing", diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index c03239af..d8ea0118 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import re import shutil import subprocess from collections.abc import Iterable, Iterator, Sequence @@ -88,7 +89,7 @@ # 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 — two markers are 3 of the 12 files +# install. The worktree seed gate stopped sharing it — two markers 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". @@ -173,6 +174,23 @@ # 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 @@ -308,6 +326,70 @@ def _renderer_unit_required(project: Path, rel: str) -> bool: 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, and only two, because both are a deterministic ``HALT:`` from + ``render_skill.py`` and therefore 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. This 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 itself DECLARES cannot refuse an + upstream that renames or reorganizes its step files. Every source is scanned, not + just the entry, because upstream substitutes tokens across all of them. + + Bounded blind spots, both in the safe direction (a false green, never a false + refusal): an unreadable or undecodable source is skipped rather than reported — + it is a HALT upstream, but a different one, with a different remediation, and + fail-open on a read fault is the same doctrine :func:`_is_renderer_stub` and + :func:`_renderer_unit_required` follow. The ``*.md`` filter likewise mirrors + upstream without being independently observable: the token pattern requires a + ``.md`` suffix, so nothing else could be a target. + """ + 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" + } + 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 @@ -451,10 +533,18 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: 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. - - Both are emitted independently of the marker check above them — different files, - different remediations, and a wholly-absent ``_bmad/`` legitimately earns both - lines beside a truncated skill. + - ``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 a file the skill does not carry + (: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. """ problems: list[Finding] = [] # Same predicate as :func:`renderer_stub_resolved`, kept inline here rather than @@ -527,6 +617,22 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: {"tree": tree, "skill": resolved, "missing_scripts": absent_scripts}, ) ) + # Unconditional: the renderer-era question lives inside the helper, so a + # future edit to the era test is one line in one place rather than a + # condition here that can drift out of step with the one above. + 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 (missing " + 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(): @@ -1046,7 +1152,7 @@ def _absent_skill_files(repo_skill: Path, worktree_skill: Path) -> list[str]: one of those has an operator remedy. The old named surface (``SKILL.md`` plus the :data:`BASE_SKILLS` markers) was 3 of - the 12 files a real ``bmad-build-auto`` install carries, and the other nine are not + 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 — diff --git a/tests/test_cli.py b/tests/test_cli.py index 611c94d5..4cc411f1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1680,6 +1680,28 @@ def test_run_aborts_when_the_renderer_central_config_is_missing(project, monkeyp 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 diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 97d52135..27fc16fe 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1181,7 +1181,7 @@ def test_a_dropped_non_marker_file_pauses_before_dispatch(project, tmp_path): 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 twelve files and cannot fall behind an + 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 diff --git a/tests/test_install.py b/tests/test_install.py index ceeb5580..7d71780e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -992,6 +992,21 @@ def _install_hunters(root, tree=".claude/skills"): (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 @@ -1379,8 +1394,18 @@ def test_renderer_config_problem_emitted_once_across_two_trees(tmp_path): 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 both lines. Different files, different - remediations (ablation: put the renderer probe in an elif).""" + 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 findings, 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 @@ -1393,6 +1418,7 @@ def test_renderer_checks_reported_beside_a_truncated_primitive(tmp_path): 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", ] @@ -1414,6 +1440,221 @@ def test_renderer_checks_are_silent_when_nothing_resolves(tmp_path): 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 twelve 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"] + + # …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"] + + +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]) == [] + + # …and the rel that gets REPORTED is the same POSIX one, on either platform + (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_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 1e5c030cb2f2e93d83077b5063f2ef2c61965d5a Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 20:13:11 -0700 Subject: [PATCH 34/41] fix(install): mirror the renderer's is-a-file guard on render sources (#405) An adversarial pass over 3de6270 found the source-set mirror was missing three of upstream's four per-candidate guards. Measured, all four: workflow.md is a DIRECTORY -> upstream HALTs, gate returned [] workflow.md is a DANGLING symlink -> upstream HALTs, gate returned [] a FIFO named pipe.md in the tree -> upstream HALTs, gate HUNG FOREVER a source symlinked OUT of the dir -> upstream HALTs, gate returns [] `path.is_file()` closes the first three. The hang is the one that mattered: the docstring claimed both blind spots were "in the safe direction (a false green, never a false refusal)", and an unbounded read of a FIFO inside `validate`/`run`/`sweep`/`resume` is neither. The escaping-symlink row is left open, named in the docstring as the ONE remaining false green rather than papered over. Four conjuncts had no witness and now have one each (ablated, whole suite, sole witness apiece): `is_file()` -> a directory-named entry (portable, so Windows CI sees it); the `*.md` glob -> customize.toml `instruction` prose quoting a token, which upstream explicitly never scans and a widened glob would refuse a healthy project over; the `OSError` half of the read guard -> a chmod-000 source (POSIX-only; the undecodable-bytes test can only raise UnicodeDecodeError); the `set()` dedupe -> a repeated token, live on the real install, which step-01 names four times. Prose corrections, each refuted by code in the same commit: "missing X" in the finding message when X is present-but-not-loadable (now "unresolved"); "deliberately NOT a fixed file list" while RENDERER_ENTRY_REL is one (scoped, and the exception named with upstream's own hardcoding as the reason); "two questions and only two" reading as exhaustive over renderer HALTs; "a file the skill does not carry" refuted by this PR's own SKILL.md test; a fresh "twelve" I introduced in a docstring one commit after fixing the other five; and "Two findings" three lines above a four-id assert. --- CHANGELOG.md | 3 +- src/bmad_loop/install.py | 66 +++++++++++++++--------- tests/test_install.py | 108 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05845863..5e93fa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,8 @@ editing. 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 a file the skill does not carry. Every route ends in 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 diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index d8ea0118..4dd8a312 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -329,10 +329,11 @@ def _renderer_unit_required(project: Path, rel: str) -> bool: def _absent_renderer_sources(skill_dir: Path) -> list[str]: """Render sources ``skill_dir`` needs and does not carry, as POSIX rels. - Two questions, and only two, because both are a deterministic ``HALT:`` from - ``render_skill.py`` and therefore 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: + 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 @@ -359,26 +360,40 @@ def _absent_renderer_sources(skill_dir: Path) -> list[str]: ``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. This 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 itself DECLARES cannot refuse an - upstream that renames or reorganizes its step files. Every source is scanned, not - just the entry, because upstream substitutes tokens across all of them. - - Bounded blind spots, both in the safe direction (a false green, never a false - refusal): an unreadable or undecodable source is skipped rather than reported — - it is a HALT upstream, but a different one, with a different remediation, and - fail-open on a read fault is the same doctrine :func:`_is_renderer_stub` and - :func:`_renderer_unit_required` follow. The ``*.md`` filter likewise mirrors - upstream without being independently observable: the token pattern requires a - ``.md`` suffix, so nothing else could be a target. + 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" + 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(): @@ -534,8 +549,9 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: 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 a file the skill does not carry + 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`` @@ -617,9 +633,11 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: {"tree": tree, "skill": resolved, "missing_scripts": absent_scripts}, ) ) - # Unconditional: the renderer-era question lives inside the helper, so a - # future edit to the era test is one line in one place rather than a - # condition here that can drift out of step with the one above. + # 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( @@ -627,7 +645,7 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]: "skills.dev-renderer-sources", "problem", f"{tree}/{resolved} renders via {RENDERER_SCRIPT_MARKER} but its " - f"render sources are incomplete (missing " + 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}, diff --git a/tests/test_install.py b/tests/test_install.py index 7d71780e..e4deba57 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1403,9 +1403,9 @@ def test_renderer_checks_reported_beside_a_truncated_primitive(tmp_path): 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 findings, and they say different things — "this install is - truncated" and "the prompt it would render references a file that is not - there".""" + 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 @@ -1448,7 +1448,7 @@ def test_renderer_stub_without_its_workflow_entry_fails_the_preflight(tmp_path): `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 twelve a real `bmad-build-auto` carries, so a truncated repo-side install + 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 @@ -1495,6 +1495,15 @@ def test_a_snapshot_token_naming_an_absent_source_fails_the_preflight(tmp_path): 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]) == [] @@ -1544,6 +1553,16 @@ def test_a_snapshot_token_naming_skill_md_is_reported(tmp_path): 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 @@ -1619,7 +1638,9 @@ def test_a_nested_snapshot_target_is_keyed_by_its_posix_rel(tmp_path): ) assert missing_base_skills(tmp_path, [tree]) == [] - # …and the rel that gets REPORTED is the same POSIX one, on either platform + # 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"] @@ -1655,6 +1676,83 @@ def test_an_undecodable_source_does_not_crash_the_preflight(tmp_path): 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 0870941d22c40b28bee05808f21f0687f9a67406 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Fri, 31 Jul 2026 22:31:13 -0700 Subject: [PATCH 35/41] fix(install): make provisioning total on a filesystem it cannot read (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_bmad/` seed and its detector adopt the skill seed's walk, so a symlinked `_bmad/scripts/lib` is no longer seeded as nothing AND reported as complete by two agreeing `rglob`s. The walk descends symlinks, so it carries a branch-local cycle guard. Per file, an undeliverable source, an occupied destination and a failed read or write become skips the completeness gate names rather than an exception out of `provision_worktree`, which `Engine._run_isolated` does not wrap — one unreadable skill file ended the whole run with a crash dump where a named, resumable escalation belonged. A dangling destination symlink counts as occupied, since 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 total, and — the part a `try` alone does not buy — reads the same on all four supported interpreters. `is_file()`/`exists()`/`is_dir()` raise on an unsearchable parent through 3.13 and answer `False` on 3.14, and for `_is_dir` `False` is the silent drop rather than the cautious reading, so a `False` is re-asked of `stat()`, which still raises there. Measured: the wrapper alone passed both `0o444` witnesses on 3.11/3.12/3.13 and FAILED them on 3.14, where the seed dropped the subtree and the gate reported nothing. The new `test_is_dir_reads_a_refused_probe_...` emulates each reading, so every lane exercises both branches. Ablations, whole suite, both interpreter lanes: `probe-whole-leg` 1 red on 3.13 and 3 on 3.14; `probe-drop-errno-filter` 25 on both (absence read as refusal makes the gate name everything); `probe-drop-isinstance` and `probe-drop-valueerror` 1 each; `walk-root-bare-isdir` 3 on 3.13 and 0 on 3.14; `isfile-bare` 6 on 3.13 and 0 on 3.14. The `0o555` control is a false-positive witness and no guard ablation reddens it, so it is pinned instead against a constant-True `_is_dir` and an `os.access(W_OK)` one, which fail its two assertions separately. Also corrects claims from earlier commits on this PR, all re-measured by hand, so the widened scope is not silent: - CHANGELOG.md:46 — 0.9.0 already hard-blocked on the over-broad tree list; it emitted `skills.base-missing` as a problem and the run preflight returned False. Only the number of ways to trip it changed in 0.9.1. - install.py:92 — SKILL.md plus the two markers is 3 of 13 files, not "two markers are 3". - install.py:1748 — 0.9.0 named `worktree_seed = ["_bmad"]` as the BROKEN example under #230, never as the workaround. - The cycle counterfactual's "85 path components" is location-dependent (ELOOP bounds the absolute path; re-measured 82 from a shallower tmpdir) while the 42-file count holds — stated that way in both copies. - The unreadable-tree witness named the gate's split as the second raiser; the frames say it is the `_merge_traversable` leg, and there are five stacked probes rather than two. Each verified by reverting exactly that one call. --- CHANGELOG.md | 36 +- src/bmad_loop/install.py | 517 ++++++++++++++++++++++++---- tests/test_install.py | 704 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1191 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e93fa94..e7ed5a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,10 @@ editing. 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, but the renderer checks - above are problems rather than warnings, so a config 0.9.0 merely nagged about became a hard - block on 0.9.1. The probe — and `validate`'s own copy of it, which had the same defect twice + 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 renderer checks above 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 @@ -122,6 +123,35 @@ editing. 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. + - **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 diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 4dd8a312..5e493183 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -16,6 +16,7 @@ from __future__ import annotations +import errno import json import re import shutil @@ -89,8 +90,8 @@ # 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 — two markers are 3 of the 13 files -# a real bmad-build-auto carries, so it asks walk parity with the repo instead +# 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" @@ -263,12 +264,23 @@ def resolve_dev_primitive(project: Path, tree: str) -> str | None: :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 (project / tree / DEV_PRIMITIVE_NEW / "SKILL.md").is_file(): + if _is_file(project / tree / DEV_PRIMITIVE_NEW / "SKILL.md"): return DEV_PRIMITIVE_NEW legacy = project / tree / DEV_PRIMITIVE_LEGACY - if (legacy / "SKILL.md").is_file() and all( - (legacy / marker).is_file() for marker in DEV_PRIMITIVE_MARKERS + if _is_file(legacy / "SKILL.md") and all( + _is_file(legacy / marker) for marker in DEV_PRIMITIVE_MARKERS ): return DEV_PRIMITIVE_LEGACY return None @@ -934,8 +946,11 @@ def _copy_traversable(src, dst: Path) -> None: dst.write_bytes(src.read_bytes()) -def _walk_traversable_files(src, rel: str = "") -> Iterator[tuple[str, object]]: - """Yield ``(rel, traversable)`` for every non-directory entry under ``src``. +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 @@ -958,28 +973,232 @@ def _walk_traversable_files(src, rel: str = "") -> Iterator[tuple[str, object]]: ``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 — 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. Callers - that might be handed a path with no dir behind it must say so themselves — - :func:`base_skills_seed_incomplete` behind its ``SKILL.md`` precondition and the - ``BASE_SKILLS`` copy loop behind ``src_root.is_dir()`` both do. Ablating either - guard turns that pair into a bogus empty rel, which is what makes them - load-bearing well beyond the one case each was written for. - - Lazy by construction and it must stay that way: an ``iterdir()`` - ``PermissionError`` surfaces mid-walk where it does today rather than at the - first ``next()``. + 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 src.is_dir(): - for child in sorted(src.iterdir(), key=lambda entry: entry.name): - yield from _walk_traversable_files(child, f"{rel}/{child.name}" if rel else child.name) + 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. @@ -997,15 +1216,54 @@ def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = 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 target.exists(): + 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 - target.parent.mkdir(parents=True, exist_ok=True) - _copy_traversable(child, target) def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None: @@ -1074,34 +1332,81 @@ def _seed_bmad_tree(worktree: Path, repo_root: Path) -> list[str]: 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 src_root.is_dir(): + if not _is_dir(src_root): return [] dst_root = worktree / BMAD_DIR - had_bmad = dst_root.is_dir() + had_bmad = _is_dir(dst_root) seeded: list[str] = [] - for top in sorted(src_root.iterdir()): + 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 src in [top] if top.is_file() else sorted(top.rglob("*")): - if not src.is_file(): + for rel, src in _walk_traversable_files(top, top.name): + if not _is_file(src): continue - rel = src.relative_to(src_root) - dst = dst_root / rel + 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 dst.exists(): + if _occupied(dst): continue - dst.parent.mkdir(parents=True, exist_ok=True) - _copy_traversable(src, dst) - seeded.append((Path(BMAD_DIR) / rel).as_posix()) + 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 @@ -1114,20 +1419,31 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: 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. Measured: a symlinked dir IS walked when it is the rglob root (which it - is in :func:`_seed_bmad_tree`), 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. + 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-vs-defer plus - notify and pause, which belongs to the caller that owns the state machine and - the journal. + 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 @@ -1139,13 +1455,13 @@ def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool: 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 (repo_root / RENDERER_SCRIPT_REL).is_file(): + 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( - src.is_file() and not (dst_scripts / src.relative_to(scripts)).is_file() - for src in scripts.rglob("*") + (_is_file(src) or _is_dir(src)) and not _is_file(dst_scripts.joinpath(*rel.split("/"))) + for rel, src in _walk_traversable_files(scripts) ) @@ -1185,12 +1501,38 @@ def _absent_skill_files(repo_skill: Path, worktree_skill: Path) -> list[str]: 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. + 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, _ in _walk_traversable_files(repo_skill) - if not worktree_skill.joinpath(*rel.split("/")).is_file() + 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("/"))) ] @@ -1240,6 +1582,36 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence 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 @@ -1282,9 +1654,18 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence continue repo_skill = repo_root / tree / skill worktree_skill = worktree / tree / skill - if not (repo_skill / "SKILL.md").is_file(): + 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 (worktree_skill / "SKILL.md").is_file(): + if not _is_file(worktree_skill / "SKILL.md"): missing.append(f"{tree}/{skill}") continue missing += [ @@ -1312,10 +1693,18 @@ def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool: 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 (repo_root / CENTRAL_CONFIG_REL).is_file() and not ( - worktree / CENTRAL_CONFIG_REL - ).is_file() + return _is_file(repo_root / CENTRAL_CONFIG_REL) and not _is_file(worktree / CENTRAL_CONFIG_REL) def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool: @@ -1483,9 +1872,11 @@ def provision_worktree( # 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 documented `worktree_seed = ["_bmad"]` as the workaround for exactly - # 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 applied. + # 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 @@ -1531,7 +1922,7 @@ def provision_worktree( # the channel the renderer sentinels share. for skill in BASE_SKILLS: src_root = repo_root / tree / skill - if not src_root.resolve().is_relative_to(repo_root) or not src_root.is_dir(): + if not src_root.resolve().is_relative_to(repo_root) or not _is_dir(src_root): continue _merge_traversable(src_root, tree_dir / skill, worktree, repo_root) diff --git a/tests/test_install.py b/tests/test_install.py index e4deba57..25c9eb8c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -717,6 +717,416 @@ def test_provision_worktree_merge_refuses_to_write_outside_the_worktree(tmp_path assert skipped == [f"{tree}/{DEV_PRIMITIVE_NEW}"] +# --------------------------------------------------------------- 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 @@ -934,6 +1344,152 @@ def test_base_skills_seed_incomplete_ignores_a_short_dir_in_the_unused_era(tmp_p 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 @@ -2277,6 +2833,154 @@ def test_provision_worktree_bmad_merge_fills_only_missing_files(tmp_path): 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"] + + 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 From d1e60af9fff1a95cf5d6abd375a1767147859758 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 08:50:51 -0700 Subject: [PATCH 36/41] fix(install): report a seed the worktree never got, and stop polluting the shared exclude (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #415: `provision_worktree`'s two explicit seed 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 no-op. The canonical trigger is a config the repo carries as a symlink OUT of itself (a dotfile-managed `.claude/settings.json`, a shared MCP config), so the worktree silently runs without it. New public `install.worktree_seed_undelivered(worktree, repo_root, seed_files, seed_globs)`, asked of the RESULT rather than accumulated in the loops — the pattern the three sibling gates already establish — and journaled by `Engine._run_isolated` under its own kind `worktree-seed-dropped`. A separate predicate rather than a second return value: 29 tests assert `provision_worktree(...) == [...]` by exact list equality plus 3 engine reads, and a tuple return breaks all 32. It also cannot ride RENDERER_SEED_SENTINELS by construction — different list, different kind, never touches `skipped`. Journaled, never escalated. The two gates beside it name files the orchestrator dispatches or the renderer HALTs on, so their absence stalls every story determinately; a seed entry is arbitrary user/plugin config whose trigger is an ordinary healthy setup, so escalating would refuse every run of such a project. Bounded deliberately to the SILENT drops: the seed loops' `_copy_traversable` still has no `try`, so a copy that FAILS raises rather than landing in the gate — which keeps CHANGELOG.md's existing "the eager copies can still raise" claim true. The source side is deliberately NOT containment-checked (re-applying the loop's own guard would make the gate agree with the loop and report nothing, which is the whole fault); the destination side IS, or the probe stats the file the loop refused to write through. `_is_file or _is_dir` on both sides because a seed entry may name a directory — which also drops dangling links and FIFOs, mirroring the copier, while an unreadable source is carried and named. F3: `/_bmad/render/` is no longer written beside a blanket `/_bmad`. Measured with `check-ignore -v`, `/_bmad` prunes the directory before git descends, so the sibling line is never consulted — and `info/exclude` sits under `--git-common-dir`, shared with the main checkout and never pruned by anything here, so it was permanent state in the operator's own repo. Read off `patterns`, not `seeded_bmad`: the root line has two producers. The shared-file pollution is now documented in `_worktree_local_exclude`, naming #384/#385's per-worktree shield as the real fix and why a patch release is not where it belongs. S8: `worktree-opened` is appended as soon as `task.worktree_path` is set, so it pairs 1:1 with `worktree-open-failed` and the escalations that promise a mounted worktree "for the operator to inspect" now record where it is. No `src/` consumer of the kind exists and no test asserts journal order. Ablations, each against the WHOLE suite with predictions written first — 16/16 rows matched exactly. Ten rows on the new gate (one per conjunct: src `_is_file` 4 reds, src `_is_dir` 3, source filter dropped 2, dst containment 1, dst `_is_file` 1, dst `_is_dir` 2, glob leg 1, dedupe 1, whole leg 7, engine call 1), three on the render shield (subsumption 1, worktree-has-bmad 1, whole leg 1) and one reverting the S8 hoist (1). The whole-leg row reddens NO pre-existing test, so the gate subsumes nothing; its three green rows are the silence controls by design. B1 fails on the line-absence assert with the post-provision `git status` protection still green — the assert order is what makes both readable in one run. The unreadable-source witness calls the gate DIRECTLY: through 3.13 the loop's own `src.exists()` raises before the gate runs while 3.14 answers False, so no end-to-end fixture can express it. Suite 2920 passed / 1 skipped on both 3.13 and 3.14 (up 12 from 2908). --- CHANGELOG.md | 21 +++ src/bmad_loop/engine.py | 45 +++++- src/bmad_loop/install.py | 137 +++++++++++++++-- tests/test_engine_worktree.py | 83 +++++++++++ tests/test_install.py | 273 +++++++++++++++++++++++++++++++++- 5 files changed, 539 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ed5a98..03248645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,27 @@ editing. 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 shared exclude gets one line fewer + (#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 or a FIFO stays silent in both halves, as it already does for the skills gate, and + the eager copies are still unchanged: a copy that _fails_ raises rather than landing here. + `/_bmad/render/` is no longer written into `.git/info/exclude` beside a blanket `/_bmad` — 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 the sibling line + was 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. + - **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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 33270c23..558fe8aa 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -37,6 +37,7 @@ dev_primitive_or_default, provision_worktree, renderer_stub_resolved, + worktree_seed_undelivered, ) from .journal import Journal, save_state from .model import ( @@ -605,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 @@ -623,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 @@ -639,6 +654,29 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> self.journal.append( "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds ) + # 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. + undelivered_seeds = worktree_seed_undelivered( + unit.path, self.paths.repo_root, seed_files=seed_files, seed_globs=seed_globs + ) + 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, @@ -728,9 +766,6 @@ def _run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> ) self._save() raise RunPaused(reason, PAUSE_ESCALATION, task.story_key) - self.journal.append( - "worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path) - ) self._save() prev = self.workspace self.workspace = unit.workspace diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 5e493183..7d574eda 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -1267,10 +1267,27 @@ def _merge_traversable(src, dst: Path, worktree: Path, repo_root: Path | None = 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. @@ -1707,6 +1724,85 @@ def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool: 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] = (), +) -> 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 and a FIFO are neither + file nor directory, so both halves drop them 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. 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. + + ⚠️ 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))] + 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 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. @@ -1815,6 +1911,11 @@ def provision_worktree( 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 and not (repo_root / BMAD_DIR).is_dir(): return [] @@ -1825,6 +1926,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 @@ -1966,12 +2071,26 @@ def provision_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 (worktree / BMAD_DIR).is_dir(): + 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. Gated on the worktree - # actually having a _bmad/ because .git/info/exclude is the COMMON git dir, - # shared with the main checkout — a line for a dir this worktree can never - # grow is pollution in the operator's own repo. + # 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"/{BMAD_DIR}/render/") _worktree_local_exclude(worktree, sorted(patterns)) return skipped diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 27fc16fe..c76998b0 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -1568,6 +1568,89 @@ def test_a_benign_skipped_seed_does_not_pause(project): 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_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 diff --git a/tests/test_install.py b/tests/test_install.py index 25c9eb8c..afdd2ad1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2781,6 +2781,220 @@ def test_provision_worktree_seed_globs_shielded_in_local_exclude(project, tmp_pa assert git(wt, "status", "--short", "--", ".claude/skills/tests-run") == "" +# ----------------------------------------------------------------- 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"] + + # ----------------------------------------------------------------- _bmad/ config surface @@ -3003,7 +3217,10 @@ def test_provision_worktree_shields_whole_bmad_when_worktree_lacked_it(project, 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.""" + 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" @@ -3038,10 +3255,17 @@ def test_provision_worktree_shields_only_seeded_files_when_bmad_committed(projec def test_provision_worktree_render_shield_only_when_bmad_present(project, tmp_path): - """`/_bmad/render/` is shielded whenever the worktree has a `_bmad/` — the - renderer rewrites that dir mid-session, long after provisioning. It is NOT - written when there is no `_bmad/` at all: `.git/info/exclude` is the COMMON git - dir, shared with the main checkout, so an ungated line is pollution there.""" + """`/_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") @@ -3052,12 +3276,49 @@ def test_provision_worktree_render_shield_only_when_bmad_present(project, tmp_pa 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) - assert "/_bmad/render/" in exclude_path.read_text(encoding="utf-8").splitlines() + 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") From dac373b7246f2c71f95ac84d29487bdf9ae0ddc7 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 10:18:59 -0700 Subject: [PATCH 37/41] fix(validate): refuse worktree isolation under a repo_root override, warn on tracked render output (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #414 fix-2: `isolation = "worktree"` is refused when `repo_root` differs from the project dir. Worktree provisioning seeds from `repo_root` while init/validate/the run preflight probe `project`, so the preflight approved a surface the isolated run never received and every seed-completeness gate went inert. New `bmadconfig.worktree_isolation_conflict` is the sole producer of the message; `validate` reports it as `policy.isolation-repo-root`, run/sweep/resume refuse, the child sweep raises, the dry-run banner lists it first and the TUI mirrors it ahead of its own clean-tree gate. #409: new `git.render-tracked` warning — a tracked `_bmad/render/` ignores both 0.9.1 shields, so validate names the one-time `git rm -r --cached`. All four spellings of the path now come from `install.RENDER_DIR_REL`. Three defects found by the review of this change and fixed here: - The child sweep declined with a bare return, which `_maybe_auto_sweep` reads as success — a sweep that never launched was journaled `sweep-auto-finished`. It raises instead, landing on the `sweep-auto-failed` path an unparseable policy.toml already takes. - `verify.path_tracked` and `worktree_clean` tested stdout and stderr merged, but `ls-files`/`status` exit 0 while writing diagnostics to stderr. That chatter read as an index entry or a porcelain line, so the new check would tell operators to untrack a path that was never committed, and an empty checkout read as dirty. Both now read stdout alone. - An undecodable policy.toml/config.yaml raised `UnicodeDecodeError`, a ValueError that escaped every `except (PolicyError, OSError)` degrade handler: the TUI could not open its dashboard and `_configure_mux` killed every CLI command at startup. Converted at both loaders, so all callers are fixed at once. Tests: 17 new. Ablations: 28 rows against the whole suite, predictions written first. 2937 passed / 1 skipped on 3.13 and 3.14.6; trunk clean at 230 files. --- CHANGELOG.md | 53 +++++ README.md | 2 +- docs/FEATURES.md | 2 +- src/bmad_loop/bmadconfig.py | 53 ++++- src/bmad_loop/checks.py | 2 + src/bmad_loop/cli.py | 122 +++++++++- src/bmad_loop/install.py | 23 +- src/bmad_loop/policy.py | 15 +- src/bmad_loop/tui/app.py | 33 ++- src/bmad_loop/tui/screens/dashboard.py | 5 +- src/bmad_loop/verify.py | 42 +++- tests/test_bmadconfig.py | 16 ++ tests/test_cli.py | 304 +++++++++++++++++++++++++ tests/test_tui_app.py | 76 ++++++- 14 files changed, 714 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03248645..66f98a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,59 @@ editing. passed, so the escalations that leave a half-provisioned worktree mounted for inspection now say where it is. +- **`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 two emptiness probes no longer read their answer out of git's stderr (#405).** + `path_tracked` and `worktree_clean` both 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 with a bare traceback 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 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 <img src="docs/images/settings-scm.png" alt="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." width="880"> </p> -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/<unit>/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/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 dbd388b9..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", diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 26dfafd4..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 @@ -388,6 +411,20 @@ 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 @@ -428,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): @@ -684,7 +747,9 @@ def _skill_trees(project: Path, pol) -> list[str]: return trees -def _warn_preflight_would_abort(project: Path, pol, *, require_stories: bool = False) -> None: +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 @@ -694,14 +759,24 @@ def _warn_preflight_would_abort(project: Path, pol, *, require_stories: bool = F ``/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(project, pol) - problems = install.missing_base_skills(project, trees) - if require_stories: - problems += install.missing_stories_support(project, trees) + 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( @@ -709,7 +784,7 @@ def _warn_preflight_would_abort(project: Path, pol, *, require_stories: bool = F file=sys.stderr, ) for problem in problems: - print(f" FAIL: {problem.message}", file=sys.stderr) + print(f" FAIL: {problem}", file=sys.stderr) print("run `bmad-loop validate` for details", file=sys.stderr) @@ -866,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: @@ -989,7 +1072,7 @@ def _dry_run( if stories_on: return _dry_run_stories(paths, pol, args, spec_folder) - _warn_preflight_would_abort(paths.project, pol) + _warn_preflight_would_abort(paths, pol) def render(role: str, prompt: str) -> str: return _render_invocation(pol, paths.project, role, prompt) @@ -1033,7 +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.project, pol, require_stories=True) + _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. @@ -1153,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, @@ -1176,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 @@ -1200,7 +1298,7 @@ def cmd_sweep(args: argparse.Namespace) -> int: def _sweep_dry_run(paths: bmadconfig.ProjectPaths, pol) -> int: - _warn_preflight_would_abort(paths.project, pol) + _warn_preflight_would_abort(paths, pol) ledger = paths.deferred_work if not ledger.is_file(): print(f"no deferred-work ledger at {ledger}") @@ -1236,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/install.py b/src/bmad_loop/install.py index 7d574eda..d8458094 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -197,7 +197,14 @@ # 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. -BMAD_SEED_EXCLUDES = ("render",) +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 @@ -1651,8 +1658,14 @@ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence 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``; those are the same path - unless a project overrides it — tracked as #414.) + 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 @@ -2091,7 +2104,7 @@ def provision_worktree( # 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"/{BMAD_DIR}/render/") + patterns.add(f"/{RENDER_DIR_REL}/") _worktree_local_exclude(worktree, sorted(patterns)) return skipped @@ -2197,7 +2210,7 @@ def install_into( # 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"{BMAD_DIR}/render/", + f"{RENDER_DIR_REL}/", ) if line not in have ] diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index 0e7ac228..a54eee65 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -603,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/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 a96ab1c7..af41b5e7 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: @@ -297,13 +315,21 @@ def path_tracked(repo: Path, rel: str) -> bool: 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.""" - rc, out = _git(repo, "ls-files", "--", rel) - if rc != 0: - raise GitError(f"git ls-files -- {rel} failed in {repo}: {out}") - return bool(out) + 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]: 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 4cc411f1..e8531fe6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,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" @@ -4393,3 +4394,306 @@ def test_dry_run_is_silent_when_preflight_would_pass(project, capsys): 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_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) From e3dba25b2c551af405ee2bdaaceb184178f25565 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 10:46:16 -0700 Subject: [PATCH 38/41] =?UTF-8?q?fix(core):=20three=20small=20contracts=20?= =?UTF-8?q?=E2=80=94=20location:=20n/a,=20per-role=20workflow=20marker,=20?= =?UTF-8?q?spec=20alias=20(#405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4. `deferredwork.append_entry` emits `location:` unconditionally, with `n/a` standing in for an empty value, reversing the doctrine its own docstring argued for. deferred-work-format.md has always given the field as `<file:line or component, or "n/a" for deferred goals>` and marks only `severity:` optional, and migration-mode.md tells the migrating session to write `n/a` — so the orchestrator's writer was the one producer disagreeing with the format the sweep skill's triage step reads. The value is stripped before it is judged empty: `devcontract._flatten` cannot deliver a whitespace-ONLY location (it collapses runs and the caller maps "" to None), but its 200-char clamp landing on a join space CAN leave a trailing one (measured). Two prose corrections in the same docstring: the field is not "already read by the TUI" (the TUI renders the entry body verbatim and never parses it), and nothing in Python extracts it at all — so entries already on disk without the line stay valid, and an absent `location:` must be read as `n/a`. The dedupe is untouched: it keys on `origin:` + `source_spec:` only, and the idempotency tests now exercise entries carrying the new line. F5. The plugin-workflow completion-marker filename resolves through `self._dev_skill(role)` rather than the defaulted dev role. A workflow declares its own role and runs on that adapter, whose skill tree can be a different one at a different upstream era (dev=claude on `.claude/skills` post-rename, review=codex on `.agents/skills` pre-rename), so the old form handed a review session a filename spelling a primitive only the dev tree carries. Read-back was never at risk — `FALLBACK_RESULT_PREFIXES` matches both spellings unconditionally — which is why this is the two halves agreeing, not a repair. S6. `spec` joins `diagnostics._JOURNAL_ALIAS_FIELDS`, in a namespace of its own. The deferral-harvest events journal a bare `spec_path.name`, which is identifier-shaped, so `scrub_json` passed the customer's feature name through verbatim; the egress backstop could only ever rescue names that happened to embed the story key, because it repairs only values already in the legend. `Pseudonymizer`'s own docstring has always listed "spec filenames" among what it exists to alias, so this was a routing gap rather than a policy. Its own namespace, not "story": the epic lookup is keyed on `ns == "story"`, so a filename routed there would render as an epic-less `story-<hex>`. Suite 2940 passed / 1 skipped on BOTH 3.13 and a real 3.14.6 venv (2937 at dac373b: +3). `trunk check --all --no-fix` clean at 230 files. Ablations: 10 rows + a sentinel + a control, each against the WHOLE suite, predictions written first. The sentinel was proven DIRECTLY rather than by reading a traceback — with PYTHONPATH=<sandbox>/src the interpreter imports the sandbox's module, without it the main checkout's, so no row is a false green. Control 0. F4 whole-leg -> the 2 n/a tests · drop `.strip()` -> the strip witness ALONE · drop the `or ''` None guard -> 25 (an AttributeError from every caller that omits a location — the crash-shaped conjunct) · drop the `or 'n/a'` fallback while keeping the line -> the same 2 as the whole leg, so "the line is always present" and "its value is n/a" are one decision with one witness pair, recorded rather than assumed · canonical doc edit without reseeding the forks -> both `test_module_skills_sync` cases, so the three-copy obligation has teeth locally (it skips in CI, where the gitignored forks are absent). F5 role -> default -> its own test ALONE. S6 field deleted -> 6; namespace -> "story" -> the alias-shape test ALONE, so routing and namespace are separately witnessed. Rows that did NOT redden, read. Under F5 the older `..._follows_the_resolved_dev_primitive` stays green: one adapter serves both roles there, so it cannot see WHICH tree resolved — the reason the new test injects a distinct review adapter. S6's 6 is 3 more than predicted and the extra 3 are `test_cli.py`, which imports `_seed_run` + `CANARIES`: the leak was reachable through the real `diagnose` command, not only the library render, and only the --json entrypoints moved because markdown renders journal aggregates and never per-entry fields. `test_no_repairs_on_fully_routed_run` stays green under S6 — the load-bearing negative, since the backstop has no alias to substitute for a value that was never routed. --- CHANGELOG.md | 16 +++++++ .../bmad-loop-sweep/deferred-work-format.md | 4 ++ src/bmad_loop/deferredwork.py | 28 +++++++---- src/bmad_loop/diagnostics.py | 12 +++++ src/bmad_loop/engine.py | 10 +++- tests/test_deferredwork.py | 46 ++++++++++++++++-- tests/test_diagnostics.py | 26 ++++++++++ tests/test_plugin_workflows.py | 48 ++++++++++++++++++- 8 files changed, 175 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f98a1e..888f370b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,6 +305,22 @@ editing. 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 marks only `severity:` optional, so the orchestrator's own + writer was the one producer disagreeing with the format the sweep skill reads. The value is + stripped before it is judged empty, so the harvest's 200-character clamp landing on a word + break no longer leaves a `location:` line with trailing whitespace; 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 hands a review session a filename spelling a primitive only the dev tree carries + (read-back was never at risk — both prefixes are matched unconditionally). And `diagnose` now + pseudonymizes the `spec` journal field: the deferral-harvest events journal a bare spec + basename, which is identifier-shaped and therefore shipped verbatim, and the egress backstop + could only ever rescue the ones that happened to embed the story key. + ## [0.9.0] — 2026-07-21 ### Added 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..b334954f 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,10 @@ reason: <why this was deferred rather than done now, one or two sentences> status: open ``` +`location:` is always written — use `n/a` when the item is a goal rather than a +place in the code. Entries written before this rule was enforced may omit the +line entirely; read an absent `location:` as `n/a`, never as "not yet known". + `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 556cb745..24c4725a 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -200,12 +200,25 @@ def append_entry( (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. - `location` is the format's optional file:line/component field (documented in - deferred-work-format.md, already read by the TUI and the sweep triage); it is - written directly after `source_spec:` so the emitted order matches the - canonical origin → location → severity → reason → status shape. Omitted - entirely when empty — a `location: n/a` line would read as an assertion the - finding has no location, which is not what an absent field means.""" + `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 `<file:line or component, or + "n/a" for deferred goals>` and marks only `severity:` optional, 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: the format says in as many words that a missing severity is fine. + + Nothing in Python parses the field — `parse_ledger` does not extract it and + the TUI renders the entry body 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 ( @@ -216,8 +229,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}`"] - if location: - lines.append(f"location: {location}") + 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/diagnostics.py b/src/bmad_loop/diagnostics.py index 6fc51647..1ad638a9 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -78,6 +78,18 @@ "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. `spec-*` journal kinds pass a + # bare basename (engine.py's harvest events), 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. + "spec": "spec", } # Journal fields that carry free text (LLM/merge prose, prompts, errors). Never # emitted — replaced with a boolean presence marker so a maintainer still learns diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 558fe8aa..51817da0 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -3061,9 +3061,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"{self._dev_skill()}-result-{task_id}.md" + / f"{self._dev_skill(role)}-result-{task_id}.md" ) prompt += WORKFLOW_COMPLETION_CONTRACT.format(marker_path=marker_path) spec = SessionSpec( diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index b55c106b..8e82c620 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -578,13 +578,49 @@ def test_append_entry_writes_location_between_source_spec_and_severity(tmp_path) assert field_line_present(parse_ledger(p.read_text())[0].body, "location", "src/retry.py:88") -def test_append_entry_omits_location_when_absent(tmp_path): - """An entry with no location carries no `location:` line at all. Writing a - placeholder would assert the finding *has* no location, which is a different - claim from the skill simply not recording one.""" +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") - assert "location:" not in p.read_text() + 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. + + The two halves are NOT equally reachable, and saying so is the point. A + whitespace-ONLY location cannot arrive from the harvest — `devcontract._flatten` + collapses runs and yields "" for a blank scalar, and the caller maps "" to None + — so that half is a promise `append_entry` makes about its own `str | None` + signature, not a shape upstream can produce. The TRAILING-space half is + reachable today: `_flatten` clamps to `_LOCATION_LIMIT` *after* joining, so a + value whose cut lands on a join space keeps that space (measured: a 200-char + clamp can end in " "), and the entry would carry a `location:` line with + trailing whitespace no other line in the format has. `field_line_present` + tolerates that padding, 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): diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 92640120..b1c8fe5a 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -27,6 +27,13 @@ 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" CANARIES = [ EMAIL, @@ -48,6 +55,7 @@ "CANARY_FEEDBACK", "CANARY_PATCH", SHA, + "AcmeVaultRotation", ] @@ -128,6 +136,7 @@ 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) for kind, fields in extra_journal or []: j.append(kind, **fields) @@ -187,6 +196,23 @@ 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 a bare spec basename, 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 the value stays correlatable across events and 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.""" + 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_structure_is_preserved(project): run_dir = _seed_run(project.project) diag, _pseudo, _combined = _render_all([run_dir]) diff --git a/tests/test_plugin_workflows.py b/tests/test_plugin_workflows.py index b4110110..0fdf288a 100644 --- a/tests/test_plugin_workflows.py +++ b/tests/test_plugin_workflows.py @@ -447,7 +447,10 @@ def test_workflow_marker_path_follows_the_resolved_dev_primitive(project): 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.""" + 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 = [] @@ -466,3 +469,46 @@ def test_workflow_marker_path_follows_the_resolved_dev_primitive(project): 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 + # both trees really were resolved independently — not one name reused twice + assert engine._dev_skill_cache == { + ".claude/skills": "bmad-build-auto", + ".agents/skills": "bmad-dev-auto", + } From 2edd753b0f681af268cfca6767759951bfdeef92 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 11:13:45 -0700 Subject: [PATCH 39/41] fix(diagnose): alias one spec under one name, and strip the location at its source (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 partial. Three defects the whole-PR adversarial review confirmed in the contracts Phase 7 shipped, plus the prose corrections two independent skeptics agreed on. The rest of Phase 8's inventory is NOT in this commit — see the plan file's Phase log. S6 was half a routing fix. `_JOURNAL_ALIAS_FIELDS`' own docstring says these fields are pseudonymized rather than dropped "so events stay correlatable", but the producers do not agree on a shape: the harvest kinds journal `spec_path.name` while the reconcile kinds journal `str(spec_path)`, which `verify.resolve_spec_path` returns ABSOLUTE, and `stories_engine` journals `task.spec_file`. Measured: one spec, two aliases in one dump (`spec-df6cebea0e63` and `spec-36092f62ea90`) — the feature failing silently in a report that looks correct. Worse, routing the field without normalizing it put an absolute home path into the local `--legend` file; before `spec` was routed at all such a value was rejected by `scrub_json` and never entered the map. The value is now reduced to its basename before it is aliased. The split is separator-agnostic by hand rather than `PurePath(...).name`, because a journal written on Windows is routinely diagnosed on POSIX, where pathlib treats a backslash as an ordinary character and would keep the whole path — and that test asserts on strings, never the filesystem, so it is a Windows witness that runs unguarded on every lane. `_flatten` now strips after clamping. The clamp is applied to the joined line, so the cut can land on a join space (measured) — and `location` is consumed TWICE from there: `harvest_fingerprint(summary, location)` becomes the ledger entry's `origin:` dedup key, and the same value is written as its `location:` line. Phase 7 cleaned only the writer, which left the key derived from a string the ledger no longer contains. Fixing it at the producer is what makes `harvest_fingerprint`'s stated invariant — "derived only from values the ledger itself preserves" — true rather than aspirational. `FALLBACK_RESULT_PREFIXES` and `DEV_PRIMITIVE_*` are independent literals in two modules and nothing derived one from the other. The failure that guards is silent and total: a marker outside the prefix set falls through to the `## Auto Run Result` branch, which the workflow-completion contract never writes, so every plugin workflow livelocks to `session_timeout_min` with no error anywhere. The new test reads the constants off the module, so a third era is enforced without anyone remembering the file exists. Subset, not equality: a prefix with no primitive is a retired era deliberately kept matchable. Prose, each confirmed by two independent skeptics reading git history rather than the current text: "marks only `severity:` optional" was over-broad (`resolution:`/`decision:` are also called optional — they are sweep annotations on an existing entry, so this writer never emits them); the format doc told readers `n/a` means "a goal rather than a place in the code" while the writer emits it for ANY empty location, including a finding whose reporter simply recorded none; `source_spec:` is half the dedupe key and was documented nowhere; and the TUI does parse an entry body — for `severity:`, not `location:`. The F5 test's `_dev_skill_cache` assertion is fixture provenance, not a second witness: it stays GREEN under the F5 ablation (measured), because that cache entry is populated by the review-prompt site the change never touched. Suite 2944 passed / 1 skipped on 3.13 (2940 at e3dba25: +4). `trunk check --all --no-fix` clean at 230 files. Ablations, whole suite each, predictions written first. Control 0. spec-normalization whole leg -> 2 (both new witnesses) · backslash class `[\\/]` -> `[/]` -> the Windows witness ALONE · drop the `or value` empty-tail fallback -> the Windows witness ALONE, on the trailing-separator assertion it carries for exactly that reason. Every row matched its prediction. --- CHANGELOG.md | 29 ++++--- .../bmad-loop-sweep/deferred-work-format.md | 15 +++- src/bmad_loop/deferredwork.py | 31 ++++--- src/bmad_loop/devcontract.py | 13 ++- src/bmad_loop/diagnostics.py | 53 ++++++++++-- tests/test_deferredwork.py | 24 +++--- tests/test_devcontract.py | 58 +++++++++++++ tests/test_diagnostics.py | 86 +++++++++++++++++-- tests/test_plugin_workflows.py | 5 +- 9 files changed, 256 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 888f370b..136e726d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,19 +307,22 @@ editing. - **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 marks only `severity:` optional, so the orchestrator's own - writer was the one producer disagreeing with the format the sweep skill reads. The value is - stripped before it is judged empty, so the harvest's 200-character clamp landing on a word - break no longer leaves a `location:` line with trailing whitespace; 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 hands a review session a filename spelling a primitive only the dev tree carries - (read-back was never at risk — both prefixes are matched unconditionally). And `diagnose` now - pseudonymizes the `spec` journal field: the deferral-harvest events journal a bare spec - basename, which is identifier-shaped and therefore shipped verbatim, and the egress backstop - could only ever rescue the ones that happened to embed the story key. + 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 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 b334954f..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,9 +35,18 @@ reason: <why this was deferred rather than done now, one or two sentences> status: open ``` -`location:` is always written — use `n/a` when the item is a goal rather than a -place in the code. Entries written before this rule was enforced may omit the -line entirely; read an absent `location:` as `n/a`, never as "not yet known". +`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 diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 24c4725a..9c33a09d 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -207,18 +207,25 @@ def append_entry( 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 `<file:line or component, or - "n/a" for deferred goals>` and marks only `severity:` optional, 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: the format says in as many words that a missing severity is fine. - - Nothing in Python parses the field — `parse_ledger` does not extract it and - the TUI renders the entry body verbatim — so entries already on disk without - the line stay valid, and readers must treat an absent `location:` as `n/a`.""" + "n/a" for deferred goals>` — 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 ( diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index b960f060..fb8cac90 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -225,10 +225,19 @@ def harvest_fingerprint(*parts: str) -> str: 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".""" + 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] + return " ".join(str(value).split())[:limit].strip() def parse_deferred_findings(fm: dict[str, Any]) -> tuple[list[DeferredFinding], list[str]]: diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 1ad638a9..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 @@ -80,17 +81,50 @@ "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. `spec-*` journal kinds pass a - # bare basename (engine.py's harvest events), 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. + # 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. @@ -373,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/tests/test_deferredwork.py b/tests/test_deferredwork.py index 8e82c620..83cee672 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -602,17 +602,19 @@ 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. - The two halves are NOT equally reachable, and saying so is the point. A - whitespace-ONLY location cannot arrive from the harvest — `devcontract._flatten` - collapses runs and yields "" for a blank scalar, and the caller maps "" to None - — so that half is a promise `append_entry` makes about its own `str | None` - signature, not a shape upstream can produce. The TRAILING-space half is - reachable today: `_flatten` clamps to `_LOCATION_LIMIT` *after* joining, so a - value whose cut lands on a join space keeps that space (measured: a 200-char - clamp can end in " "), and the entry would carry a `location:` line with - trailing whitespace no other line in the format has. `field_line_present` - tolerates that padding, which is exactly why the second half asserts the raw - text instead of going through it.""" + 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") diff --git a/tests/test_devcontract.py b/tests/test_devcontract.py index 1a4be160..034b13b5 100644 --- a/tests/test_devcontract.py +++ b/tests/test_devcontract.py @@ -809,6 +809,64 @@ def test_parse_deferred_findings_flattens_multiline_block_scalars(tmp_path): 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) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index b1c8fe5a..11733b5e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -34,6 +34,8 @@ # 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, @@ -137,6 +139,11 @@ 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) @@ -197,13 +204,19 @@ def test_pseudonymization_is_stable_and_correlates(project): def test_spec_name_is_aliased_in_its_own_namespace(project): - """The `spec-*` journal kinds carry a bare spec basename, 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 the value stays correlatable across events and 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 `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) @@ -213,6 +226,65 @@ def test_spec_name_is_aliased_in_its_own_namespace(project): 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_plugin_workflows.py b/tests/test_plugin_workflows.py index 0fdf288a..b9497f2b 100644 --- a/tests/test_plugin_workflows.py +++ b/tests/test_plugin_workflows.py @@ -507,7 +507,10 @@ def test_workflow_marker_path_follows_its_own_roles_tree(project): 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 - # both trees really were resolved independently — not one name reused twice + # 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", From 79a8fd04eb7341e71e521dbbcfb0551d5df74809 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 12:51:10 -0700 Subject: [PATCH 40/41] fix(engine): the harvest's own ledger write is not the session's proof of work (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_harvest_spec_deferrals` runs four statements above `_verify_dev_artifacts`, and `verify_dev_exclude_relpaths` deliberately leaves the deferred-work ledger un-excluded (a story whose whole authorized scope is ledger reconciliation must register as real work — KNOWN-BUG-ledger-only-story-false-no-changes). So the proof-of-work gate could not tell the ORCHESTRATOR's write from the SESSION's, and a session that finalized its spec, changed no code and recorded one `deferred:` finding PROCEEDed to done on the strength of the harvest alone. Moving the sweep's bundle close below the gate audited only the write it moved. The exclusion is keyed on a PERSISTED `StoryTask.harvest_wrote_ledger`, not on the harvest's local `filed` and not on an instance attribute: a crash replay re-runs the harvest, which dedupes against the dead attempt's entries, so `filed` comes back empty while those entries are still in the tree — a re-derived flag reads False there and the bug returns. Set-only within an attempt, cleared in the same `if not replayed:` block that arms the ledger snapshot, so a later attempt is never charged for an earlier one's harvest. All three `_verify_dev_artifacts` overrides pass it through (Engine, SweepEngine, StoriesEngine). Ten tests: 6 in test_engine.py, 1 in test_sweep.py, 1 in test_stories_engine.py, 2 round-trips in test_model.py, plus a `write_src` knob on `conftest.bundle_dev_effect` and on `stories_dev_effect`. Ablation matrix C2-0..C2-H, every row against the whole suite, all nine matching prediction exactly with zero existing-test collateral (every row totals 2955): whole-leg revert reddens 6; K2.1→C2-C, K2.2→C2-B, K2.3→C2-D, K2.4→C2-E, K2.5→C2-F, K2.6→C2-H, K2.7→C2-G each redden alone or in their predicted set. C2-H and its test T2j were added this session — the original matrix had no row for the StoriesEngine pass-through, and it shipped unwitnessed until measured. Sandbox E2E through the real CLI, ablated both ways: on the fix a harvest-only session reports `0 done, 1 deferred` with reason "no changes in worktree since baseline commit"; with the production leg reverted the same sandbox reports `1 done`. --- CHANGELOG.md | 15 +++ src/bmad_loop/engine.py | 47 +++++++- src/bmad_loop/model.py | 16 +++ src/bmad_loop/stories_engine.py | 1 + src/bmad_loop/sweep.py | 6 +- src/bmad_loop/verify.py | 29 ++++- tests/conftest.py | 13 +- tests/test_engine.py | 206 ++++++++++++++++++++++++++++++++ tests/test_model.py | 18 +++ tests/test_stories_engine.py | 43 ++++++- tests/test_sweep.py | 62 ++++++++++ 11 files changed, 444 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 136e726d..1a982bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -271,6 +271,21 @@ editing. 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 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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 51817da0..14103071 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1946,6 +1946,12 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None if not replayed: task.pre_harvest_ledger = self._ledger_text() task.pre_harvest_ledger_captured = True + # Same block, same reason: everything below this line that + # touches the ledger is the ORCHESTRATOR writing, not the + # session. Cleared here so each attempt answers for itself, + # and NOT on a replay — the dead attempt's harvest is still in + # the tree, so its True must survive (see the field's comment). + task.harvest_wrote_ledger = False self._save() # bmad-dev-auto sometimes finalizes the spec in prose (## Auto Run # Result: Status done) but leaves the frontmatter status at the @@ -2859,6 +2865,13 @@ def _harvest_spec_deferrals(self, task: StoryTask, result_json: dict | 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, @@ -2901,9 +2914,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): diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 0bf93081..85021ff7 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -178,6 +178,20 @@ class StoryTask: # 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 @@ -272,6 +286,7 @@ def to_dict(self) -> dict[str, Any]: "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, @@ -333,6 +348,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask": 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/stories_engine.py b/src/bmad_loop/stories_engine.py index 83e2133d..3e0be157 100644 --- a/src/bmad_loop/stories_engine.py +++ b/src/bmad_loop/stories_engine.py @@ -446,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 baea3bca..e9676704 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -1303,7 +1303,11 @@ def _reopen_ledger_after_defer(self, task: StoryTask) -> None: 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/verify.py b/src/bmad_loop/verify.py index af41b5e7..5d65560b 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -1308,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. @@ -1317,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") @@ -1334,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 @@ -1355,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: @@ -1379,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: @@ -1411,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. @@ -1483,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 d2593461..d5b267ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -761,6 +761,7 @@ def bundle_dev_effect( 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 @@ -768,14 +769,18 @@ 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`` / ``deferred`` 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.""" + / ``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, deferred=deferred) diff --git a/tests/test_engine.py b/tests/test_engine.py index 13902050..077c11ea 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6842,3 +6842,209 @@ def boom(*a, **kw): 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 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_model.py b/tests/test_model.py index b020a155..4511da0b 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -197,6 +197,24 @@ def test_pre_harvest_ledger_defaults_disarmed_for_legacy_state(): 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_stories_engine.py b/tests/test_stories_engine.py index 9ff09c8d..0fb27f7f 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -68,10 +68,15 @@ def stories_dev_effect( 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"] @@ -81,7 +86,8 @@ 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") + 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", @@ -1305,3 +1311,36 @@ def test_plan_halt_leg_does_not_harvest_then_implement_leg_does(project): 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 d499087f..28acb9af 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -2659,3 +2659,65 @@ def test_generic_bundle_harvests_spec_deferrals_alongside_its_closures(project): 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()} From 0a8088f9adbe7d555ef6dce2de88c7b72cde09f5 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Sat, 1 Aug 2026 13:28:40 -0700 Subject: [PATCH 41/41] fix(engine): the pre-harvest snapshot is spent by a pause, not held across it (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollback_on_failure = off` is the production DEFAULT and it does not roll back: it hands the tree to a human and raises out of `_pause_for_manual_recovery`. That raise skips the retry leg's clear site, so the attempt's pre-harvest ledger snapshot stayed armed in state.json for however long the operator took — and `resume`, which replays that same attempt, wrote those stale bytes back over whatever they had done in the meantime. Every ledger state loses work that way, not just the harvest-created one: the `snapshot is None` arm UNLINKS, and the overwrite arm has no `_ledger_is_gits_to_restore` guard at all, so a TRACKED ledger is rewritten too. Nothing spent the arm either, so it happened again on every subsequent resume, not only the first. The pause leg now latches its own exception type, disarms in the same `finally` that restores and strictly AFTER it, and `_save()`s immediately — the whole point is the value the RESUME reads off disk. The arm site's guard is relaxed so a replay that finds nothing armed re-arms from the tree as the operator left it, which gives the resumed attempt's own harvest something to revert to if it fails in turn. The two `ledger-snapshot-missing` journal assertions become `not in`: those replays now re-arm rather than proceed blind, and both tests' LEDGER assertions still pass. `harvest_wrote_ledger`'s per-attempt clear is hoisted out of that relaxed guard into its own `if not replayed:`. Sharing one guard coupled the two writes: the relaxation fires on a replay with nothing armed, which is exactly the replay whose only diff IS the dead attempt's harvest, so the clear handed commit 1's proof-of-work gate the orchestrator's own write. Invisible to the suite for two independent reasons — the existing unarmed-replay tests run an effect that writes source, and the baseline-mismatch check returns before the has-changes probe. Eight tests: T1a-T1f cover the four ledger states, three consecutive resumes, and a pause with no harvest at all; T1h is the coupling witness; T1i reads state.json off disk at the `run-paused` journal line, because `run()`'s ambient `finally: self._save()` re-persists the disarm and would otherwise leave the disarm's own `_save()` unpinned. Ablation matrix C1-0..C1-G, every row against the whole suite, every row totalling 2963 ⇒ zero existing-test collateral except one named row. K1.1->C1-E, K1.2->C1-B, K1.2's save->C1-D, K1.3->C1-C, K1.4->C1-G, the ordering->C1-F, whole leg->C1-A. Two rows and two tests were added by measurement rather than plan: C1-D was predicted 0 red before it ran (hence T1i), and the hoist plus C1-G/T1h came from the coupling found in the parked code. C1-F reddened one MORE test than predicted — `test_harvest_reverted_when_rollback_pauses`, which predates both commits and asserts the same first-pause revert T1a does; the ordering constraint has an older witness than the ones written for it. Sandbox E2E through the real CLI, ablated both ways with a sentinel proving which tree imported: on the fix a paused harvest-only run whose operator writes a ledger during the pause still has that ledger after `resume`; with the production leg reverted the same sandbox deletes it. --- CHANGELOG.md | 17 ++- src/bmad_loop/engine.py | 79 +++++++++-- tests/test_engine.py | 298 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 373 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a982bfd..fa0a63e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -267,10 +267,25 @@ editing. 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. A defer still + 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 diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 14103071..ad991e86 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -1938,20 +1938,43 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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: 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. + # 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 - # Same block, same reason: everything below this line that - # touches the ledger is the ORCHESTRATOR writing, not the - # session. Cleared here so each attempt answers for itself, - # and NOT on a replay — the dead attempt's harvest is still in - # the tree, so its True must survive (see the field's comment). - task.harvest_wrote_ledger = False + # 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 @@ -2040,8 +2063,19 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None feedback = self._write_feedback(task, decision.reason) else: feedback = None + 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 @@ -2062,6 +2096,29 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None # 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; diff --git a/tests/test_engine.py b/tests/test_engine.py index 077c11ea..c4a778fe 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6181,15 +6181,21 @@ def test_restore_ledger_keeps_a_tracked_ledger_the_reset_restored(project): def test_crash_replay_leaves_the_ledger_alone_when_no_snapshot_was_persisted(project): - """A replay that finds nothing armed must keep its hands off entirely. + """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, and both readings of a guess are 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 harvest surviving is the honest cost of - the one window that remains, and the skip is journalled rather than silent. + 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.""" @@ -6214,8 +6220,10 @@ def test_crash_replay_leaves_the_ledger_alone_when_no_snapshot_was_persisted(pro 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"]] - assert "ledger-snapshot-missing" in [e["kind"] for e in resumed.journal.entries()] + # ...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 @@ -6301,7 +6309,13 @@ def test_a_replay_never_restores_an_earlier_attempts_snapshot(project): 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, and the replay correctly finds nothing.""" + 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)) @@ -6326,7 +6340,8 @@ def test_a_replay_never_restores_an_earlier_attempts_snapshot(project): 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" in kinds # ...and it found nothing armed + 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"] @@ -6844,6 +6859,271 @@ def boom(*a, **kw): 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