From db08a5c2385765095178ce0c9ba4ea052dda7fa9 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 15 Jul 2026 11:33:04 -0700 Subject: [PATCH] fix(worktree): survive the renderer-era bmad-dev-auto (BMAD-METHOD #2587/#2588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream bmad-dev-auto's SKILL.md is now a two-line shim that runs `uv run render.py` and follows its stdout; the renderer anchors its project root on the first _bmad/ dir walking UP from the session cwd, requires the post-#2285 _bmad/config.toml, and rewrites _bmad/render/bmad-dev-auto/ with absolute paths baked in. The completion contract bmad-loop parses is unchanged — the breakage is environmental: - Worktrees nest under /.bmad-loop/runs/ and never carried a _bmad/, so render.py silently adopted the MAIN checkout's: rendered workflows baked main-checkout absolute artifact paths (deferred-work appends, no-spec fallback artifacts, and step-01 context escaped the worktree — fallbacks landing where _artifact_dirs never looks) and every parallel session raced on one shared, delete-rewritten render dir. provision_worktree now merge-seeds the main repo's _bmad/ config surface per-file (copy-when-absent: a committed _bmad/ keeps every tracked file; the gitignored *.user.toml layers — whose absence hard-HALTs render.py since #2588 — are filled in), never seeds the regenerated render/, and shields it: one anchored /_bmad exclude when the checkout had none, per-file rels when merging into an existing tree. - /_bmad/render/ is always excluded (seeded or not) so session-rendered output can't be swept into story commits by the unit's `git add -A`; `bmad-loop init` also gitignores _bmad/render/ for in-place runs. - The shim HALTs on any failure BEFORE the workflow's HALT protocol is loaded, so a broken environment (no uv, no central config, missing render.py) yields one result-less Stop per story with no spec status ever written. A renderer preflight (validate + run/sweep/resume start) now fails loudly instead — gated on the installed SKILL.md actually being the render shim, so pre-render installs see zero new checks and byte-stable validate output. - `validate --render-probe` (opt-in) executes the real renderer against the project to surface render-time HALTs (#2588: missing {{.var}} keys, unparseable override layers) before a run burns a story. - validate warns (never fails) when the four-layer central TOML and the legacy _bmad/bmm/config.yaml disagree on implementation_artifacts (the renderer follows the TOML, the orchestrator reads the yaml — TOML-first migration tracked in #154), and once when a project has already committed _bmad/render/** (excludes can't shield tracked files; `git rm -r --cached` remediation printed). --- CHANGELOG.md | 24 ++++ CONTRIBUTING.md | 2 + docs/FEATURES.md | 4 +- docs/setup-guide.md | 16 +++ src/bmad_loop/bmadconfig.py | 76 ++++++++++++ src/bmad_loop/cli.py | 77 ++++++++++++ src/bmad_loop/install.py | 182 +++++++++++++++++++++++++++- tests/test_bmadconfig.py | 68 +++++++++++ tests/test_cli.py | 125 +++++++++++++++++++ tests/test_engine_worktree.py | 38 ++++++ tests/test_install.py | 218 ++++++++++++++++++++++++++++++++++ 11 files changed, 826 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bbf1f09..e10e7782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ breaking changes may land in a minor release. ### Added +- **Renderer-era `bmad-dev-auto` preflight (BMAD-METHOD #2587/#2588).** When the installed + skill's SKILL.md is the new `uv run render.py` shim, `bmad-loop validate` (and every + run/sweep/resume start) now requires `render.py` next to it, `uv` on PATH, and the + post-#2285 central config at `_bmad/config.toml` — any of these missing used to surface + only as a result-less Stop on every story, since the shim HALTs before the workflow's + HALT protocol exists. `validate --render-probe` opt-in executes the real renderer to + surface render-time HALTs (missing config keys, unparseable overrides) before a run; + validate also warns when the central TOML and the legacy `_bmad/bmm/config.yaml` + disagree on `implementation_artifacts`, and (once) when a project has committed + `_bmad/render/**`. Pre-render skill installs get zero new checks. + - **Out-of-tree multiplexer backends (`bmad_loop.mux_backends` entry points).** A backend package installed next to bmad-loop (e.g. `uv tool install bmad-loop --with `) now registers itself with no config step: before every selection, core imports each module @@ -145,6 +156,19 @@ PATH)`, the TUI notifies `multiplexer backend unavailable — launch/attach disa ### Fixed +- **Worktree isolation survives the renderer-era `bmad-dev-auto` (BMAD-METHOD #2587).** The new + `render.py` anchors its project root on the first `_bmad/` dir walking UP from the session cwd — + and worktrees nest inside the project, so a checkout without its own `_bmad/` silently adopted the + MAIN checkout's: rendered workflows baked main-checkout absolute artifact paths (deferred-work + appends, no-spec fallback artifacts, and step-01 context all escaped the worktree) and every + parallel session raced on one shared `_bmad/render/`. `provision_worktree` now merge-seeds the + main repo's `_bmad/` config surface per-file (copy-when-absent — a committed `_bmad/` keeps every + tracked file; the gitignored `*.user.toml` layers, whose absence hard-HALTs render.py since + #2588, are filled in), never seeds the regenerated `_bmad/render/`, and always shields + `/_bmad/render/` from the unit's `git add -A` so session-rendered output (machine-absolute paths + baked in) can't be swept into story commits. `bmad-loop init` also gitignores `_bmad/render/` for + in-place (`isolation = "none"`) runs. + - **`branch_per=run` + `keep_failed` no longer poisons a multi-story run after the first kept failure (#138).** The first story to end deferred under `keep_failed=true` left its worktree checked out on the single shared run branch, so every subsequent story's `git worktree add` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afcf6e57..5eb30185 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,6 +92,8 @@ trunk check # lint + format verification (what CI runs) Releases are cut by maintainers. The version field is validated in CI; if you touch it, run `uv run --no-project python scripts/sync_version.py --check`. +**Dogfooding against real skills:** the orchestrator drives the _upstream_ `bmad-dev-auto` + `bmad-review-*` skills, which are installed per-project (gitignored `.claude/skills/` / `.agents/skills/`), not bundled here. Before real smoke runs, keep those installs current with upstream BMAD-METHOD — the renderer-era skill (≥ commit `1cd4a7f5`, #2587/#2588) ships `render.py` + `workflow.md` alongside SKILL.md, and `bmad-loop validate` checks its environment (`uv` on PATH, `_bmad/config.toml`). + --- ## Pull Request Guidelines diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cad7019a..af87c532 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -68,7 +68,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Off by default (`[scm] isolation = "none"` — work in place on the checked-out branch, byte-for-byte the prior behavior). Set `isolation = "worktree"` and each story (and each sweep bundle) runs in its own `git worktree` on a `bmad-loop/[/]` branch cut from the target branch, then merges back **locally** — the main checkout stays free while a run is in flight. - Merge knobs: `merge_strategy` (`ff` / `merge` / `squash`), `target_branch` (default = branch checked out at run start; created if missing — a detached HEAD or unborn repo pauses the run instead of merging onto an unreferenced commit), `branch_per` (`story` or a shared `run` branch; `run` forces `delete_branch = false`), and `delete_branch`. - Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (`keep_failed`, default on) and its full diff is preserved to `run_dir/failed//changes.patch`; `failed_diff_max_mb` caps per-file untracked-file size (oversized skipped with a marker), `failed_diff_unlimited` lifts the cap. -- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent, seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A`. +- 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`. The `_bmad/` config surface is merge-seeded per-file the same way (minus the regenerated `_bmad/render/`, which is always shielded): the renderer-era `bmad-dev-auto` (BMAD-METHOD#2587) anchors its project root on the first `_bmad/` dir walking up from the session cwd, so a worktree without one would silently adopt the main checkout's — and even committed-`_bmad` projects gitignore the `*.user.toml` layers the renderer may need (BMAD-METHOD#2588). - 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). - `commit_message_template` (`{story_key}` / `{run_id}` substituted) customizes story/bundle commit messages. @@ -167,7 +167,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Setup & install - `bmad-loop init` installs the three `bmad-loop-*` skills (`bmad-loop-setup`, `bmad-loop-resolve`, `bmad-loop-sweep`, into `.claude/skills/` and/or `.agents/skills/`), the hook relay, `.bmad-loop/policy.toml`, and a gitignore covering the runs dir, plugin caches, and policy.toml itself (per-machine config). Flags: `--cli` (repeatable), `--no-skills`, `--force-skills`. -- `bmad-loop validate` preflights every prerequisite: BMAD config, sprint-status, git, the selected terminal-multiplexer backend (listing all detected when more than one is registered), CLI binary, hook registration, and the three inline review-hunter skills `bmad-dev-auto` invokes (`bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, `bmad-review-verification-gap`) plus its `customize.toml`. +- `bmad-loop validate` preflights every prerequisite: BMAD config, sprint-status, git, the selected terminal-multiplexer backend (listing all detected when more than one is registered), CLI binary, hook registration, and the three inline review-hunter skills `bmad-dev-auto` invokes (`bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, `bmad-review-verification-gap`) plus its `customize.toml`. When the installed `bmad-dev-auto` is the renderer-era shim (BMAD-METHOD#2587), validate also requires `render.py`, `uv` on PATH, and `_bmad/config.toml`; `--render-probe` additionally executes the real renderer to surface render-time config HALTs (#2588) before a run, and validate warns on TOML/yaml artifact-path desync or an already-committed `_bmad/render/`. - Non-invasive: drives the upstream `bmad-dev-auto` skill unmodified — there is no fork to keep in sync — and review is just a re-invocation of it on the `done` spec. Your standard BMAD install is never modified. ### Command reference diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 658099a1..0113e7f2 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -20,6 +20,11 @@ of the README. ## Platform prerequisites - **Python 3.11+** and a supported coding CLI (`claude` by default). +- **[uv](https://docs.astral.sh/uv/)** on the login-shell PATH — the renderer-era + `bmad-dev-auto` (BMAD-METHOD #2587) starts every dev session with + `uv run render.py`, and sessions run in fresh multiplexer panes that only see the + login-shell PATH. A missing `uv` makes every story fail with a result-less stop; + `bmad-loop validate` checks for it up front (pre-render skill installs skip the check). - **A terminal multiplexer** — the orchestrator drives agent sessions through a terminal multiplexer: **tmux** is the bundled default, and additional backends install as separate packages that register themselves (e.g. the @@ -223,6 +228,17 @@ bmad-loop validate --project yet, or `bmad-sprint-planning` hasn't run). On a fresh project that is **expected** — read its output as a readiness checklist, not an install failure. +When the installed `bmad-dev-auto` is the renderer-era shim (BMAD-METHOD #2587), `validate` +also checks its environment: `render.py` next to the shim, `uv` on PATH, and the post-#2285 +central config at `_bmad/config.toml`. Add `--render-probe` to go further and actually +execute `uv run render.py` against the project — this surfaces render-time HALTs (a config +key referenced by the skill's templates but missing from the merged config, or an +unparseable customization override — BMAD-METHOD #2588) before a run burns a story. The +probe rewrites `_bmad/render/bmad-dev-auto/`, a cache the skill regenerates on every entry. +`_bmad/render/` must never be committed: `bmad-loop init` gitignores it, worktree runs +shield it, and `validate` warns once if a project has already committed it +(`git rm -r --cached _bmad/render` fixes that). + For the dashboard itself, see [docs/tui-guide.md](tui-guide.md). For the full policy reference, see the [Policy section](../README.md#policy-bmad-looppolicytoml) of the README. diff --git a/src/bmad_loop/bmadconfig.py b/src/bmad_loop/bmadconfig.py index a29b49e3..21bac5cb 100644 --- a/src/bmad_loop/bmadconfig.py +++ b/src/bmad_loop/bmadconfig.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -67,6 +68,81 @@ def _resolve(raw: str, project: Path) -> Path: return Path(raw.replace("{project-root}", str(project))).resolve() +# The four-layer central TOML config (BMAD-METHOD #2285), lowest priority first. +_TOML_LAYERS = ( + ("config.toml",), + ("config.user.toml",), + ("custom", "config.toml"), + ("custom", "config.user.toml"), +) + + +def _toml_merge(base: object, override: object) -> object: + """Dict-aware deep merge, matching the renderer's central-config semantics: + tables deep-merge, everything else the override wins.""" + if isinstance(base, dict) and isinstance(override, dict): + result = dict(base) + for key, value in override.items(): + result[key] = _toml_merge(result[key], value) if key in result else value + return result + return override + + +def toml_artifact_parity(project: Path, paths: ProjectPaths) -> str | None: + """Warn when the central TOML and the legacy yaml disagree on the artifact dir. + + The renderer-era bmad-dev-auto bakes `implementation_artifacts` from the + four-layer `_bmad/config.toml` merge, while `load_paths` (and so the whole + orchestrator) reads the legacy `_bmad/bmm/config.yaml`. The installer + regenerates the yaml from the TOML, but a hand-added `custom/config*.toml` + override desyncs the two until the next install — the skill then writes + specs and the deferred-work ledger where the orchestrator is not looking. + + Mirrors the renderer's precedence: layers deep-merge lowest-to-highest, + then `[modules.bmm]` beats `[core]` on collision. Returns a warning string + on mismatch; None when they agree, when the TOML surface is absent or + unparseable (the renderer preflight owns reporting that), or when the TOML + never sets the key. Follow-up: make load_paths read the TOML as primary + (issue #154) — this parity check retires with it. + """ + project = project.resolve() + bmad_dir = project / "_bmad" + if not (bmad_dir / "config.toml").is_file(): + return None + merged: object = {} + for parts in _TOML_LAYERS: + layer = bmad_dir.joinpath(*parts) + if not layer.is_file(): + continue + try: + with layer.open("rb") as fh: + doc = tomllib.load(fh) + except (tomllib.TOMLDecodeError, OSError): + return None + merged = _toml_merge(merged, doc) + if not isinstance(merged, dict): + return None + modules = merged.get("modules") + bmm = modules.get("bmm") if isinstance(modules, dict) else None + raw: str | None = None + for section in (merged.get("core"), bmm): # bmm wins on collision, like render.py + if isinstance(section, dict) and isinstance(section.get("implementation_artifacts"), str): + raw = section["implementation_artifacts"] + if raw is None: + return None + toml_resolved = _resolve(raw, project) + if toml_resolved == paths.implementation_artifacts: + return None + return ( + f"central TOML config resolves implementation_artifacts to {toml_resolved}, " + f"but the legacy _bmad/bmm/config.yaml (which bmad-loop reads) resolves it " + f"to {paths.implementation_artifacts} — the renderer-era bmad-dev-auto " + f"follows the TOML, so specs and the deferred-work ledger would land where " + f"the orchestrator is not looking; re-run the BMAD installer to regenerate " + f"the yaml (bmad-loop will move to the TOML as primary — issue #154)" + ) + + def load_paths(project: Path) -> ProjectPaths: project = project.resolve() config_path = project / "_bmad" / "bmm" / "config.yaml" diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 82f95132..eae9269c 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -214,6 +214,41 @@ def _platform_preflight() -> tuple[list[str], list[str]]: return notes, problems +def _run_render_probe(project: Path, tree: str) -> str | None: + """Execute bmad-dev-auto's render.py the way a dev session would. + + The static renderer preflight can't see render-time failures — a config key + referenced by the skill's templates but missing from the merged central TOML, + or an unparseable customization layer, HALTs render.py (BMAD-METHOD #2588) + and every story becomes a result-less Stop. Running the real thing from the + project root surfaces those HALTs at validate time. Side effect (documented + on the flag): rewrites /_bmad/render/bmad-dev-auto/, a cache the + skill regenerates on every entry anyway. Returns a problem string, or None + when the renderer printed its dispatch line. + """ + script = project / tree / install.STORIES_PROBE_SKILL / "render.py" + # which() resolves PATHEXT shims (uv.cmd) that a bare CreateProcess argv0 + # would miss on Windows; the static preflight already required uv on PATH. + uv = shutil.which("uv") + if uv is None: + return "render probe: `uv` disappeared from PATH" + try: + proc = subprocess.run( # noqa: S603 — fixed argv, no shell + [uv, "run", str(script)], + cwd=project, + capture_output=True, + text=True, + timeout=120, + ) + except (OSError, subprocess.SubprocessError) as e: + return f"render probe could not run `uv run {script}`: {e}" + out = proc.stdout.strip() + if proc.returncode != 0 or not out.startswith("read and follow "): + detail = out or proc.stderr.strip() or f"exit code {proc.returncode}" + return f"render probe: `uv run render.py` did not produce its dispatch line — {detail}" + return None + + def cmd_validate(args: argparse.Namespace) -> int: project = _project(args) problems: list[str] = [] @@ -311,6 +346,40 @@ def cmd_validate(args: argparse.Namespace) -> int: notes.append("upstream skills present (bmad-dev-auto + review hunters)") problems.extend(base_problems) + # Renderer-era bmad-dev-auto (SKILL.md = `uv run render.py` shim): a broken + # render environment HALTs before the workflow's HALT protocol exists, so no + # spec status is ever written — catch it here instead of one result-less + # Stop per story. Pre-render skills get zero checks (and no note). + skill_trees = [p.skill_tree for p in profiles] + shim_trees = install.renderer_shim_trees(project, skill_trees) + renderer_problems = install.missing_renderer_support(project, skill_trees) + problems.extend(renderer_problems) + if shim_trees and not renderer_problems: + notes.append("bmad-dev-auto renderer preflight OK (render.py + uv + _bmad/config.toml)") + if getattr(args, "render_probe", False): + if not shim_trees: + notes.append( + " warning: --render-probe skipped — installed bmad-dev-auto is pre-render" + ) + elif renderer_problems: + notes.append( + " warning: --render-probe skipped — fix the renderer failures above first" + ) + else: + probe_problem = _run_render_probe(project, shim_trees[0]) + if probe_problem: + problems.append(probe_problem) + else: + notes.append("render probe OK: render.py produced its dispatch line") + + if paths: + parity = bmadconfig.toml_artifact_parity(project, paths) + if parity: + notes.append(f" warning: {parity}") + tracked_render = install.tracked_render_warning(project) + if tracked_render: + notes.append(f" warning: {tracked_render}") + for note in notes: print(f" ok: {note}") for problem in problems: @@ -454,6 +523,7 @@ def _require_base_skills(project: Path, pol, *, require_stories: bool = False) - except ProfileError: continue problems = install.missing_base_skills(project, skill_trees) + problems += install.missing_renderer_support(project, skill_trees) if require_stories: problems += install.missing_stories_support(project, skill_trees) if problems: @@ -1785,6 +1855,13 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: help="validate stories mode against this epic spec folder's stories.yaml " "(overrides [stories].source; skips the sprint-status gate)", ) + validate_p.add_argument( + "--render-probe", + action="store_true", + help="also execute the renderer-era bmad-dev-auto's render.py via `uv run` " + "against this project, surfacing render-time config HALTs before a run " + "(side effect: rewrites _bmad/render/bmad-dev-auto/, a regenerated cache)", + ) mux_p = add( "mux", diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index f11d8ce1..1db0ab56 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -94,6 +94,25 @@ STORIES_PROBE_FILE = "step-01-clarify-and-route.md" STORIES_PROBE_TEXT = "folder+id dispatch" +# Renderer-era bmad-dev-auto (BMAD-METHOD #2587): SKILL.md is a two-line shim that +# runs `uv run {skill-root}/render.py` and follows its stdout. The shim HALTs on +# any failure BEFORE the workflow's HALT protocol exists, so a broken environment +# (no uv, no central config) yields a result-less Stop for every story instead of +# one loud preflight failure. This literal is stable prose in the shim; a +# pre-render SKILL.md never mentions it, which is what gates the checks off for +# older installs. +RENDERER_PROBE_TEXT = "render.py" + +# BMAD's config/tool dir at the project root. The renderer resolves the project +# root by walking UP from the session cwd to the first _bmad/ directory, so an +# isolated worktree must carry one (see _seed_bmad_tree). +BMAD_DIR = "_bmad" +# Top-level _bmad/ entries never seeded into worktrees: render/ is render.py's +# regenerated-per-checkout output with absolute paths baked in — seeding the main +# checkout's copy would carry ITS paths into the worktree (the session re-renders +# on skill entry anyway). +BMAD_SEED_EXCLUDES = ("render",) + def missing_stories_support(project: Path, trees: Sequence[str]) -> list[str]: """Problems for stories mode's stricter bmad-dev-auto requirement. @@ -159,6 +178,65 @@ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[str]: return problems +def renderer_shim_trees(project: Path, trees: Sequence[str]) -> list[str]: + """The CLI skill trees whose installed bmad-dev-auto SKILL.md is the render shim. + + Deduped, in first-seen order. Absent/unreadable SKILL.md never qualifies — + reporting that is ``missing_base_skills``' job. + """ + found: list[str] = [] + for tree in dict.fromkeys(trees): + probe = project / tree / STORIES_PROBE_SKILL / "SKILL.md" + try: + text = probe.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if RENDERER_PROBE_TEXT in text: + found.append(tree) + return found + + +def missing_renderer_support(project: Path, trees: Sequence[str]) -> list[str]: + """Problems for the renderer-era bmad-dev-auto's environment requirements. + + When the installed SKILL.md is the render shim (BMAD-METHOD #2587), every dev + session starts by running `uv run render.py`, which requires `uv` on the + session PATH and a post-#2285 central config at `_bmad/config.toml` — and a + shim without its render.py next to it can never start at all. Any of these + missing makes the shim HALT before the workflow's HALT protocol is loaded, so + no spec status or `## Auto Run Result` is ever written: every story becomes a + result-less Stop. Check once at preflight instead. + + A pre-render SKILL.md (no ``render.py`` mention) gets zero checks — older bmm + installs carry their config load inside the workflow and stay self-sufficient. + Absent/unreadable SKILL.md is ``missing_base_skills``' job, not re-reported + here. Returns human-readable problem strings; empty list means OK. + """ + problems: list[str] = [] + shim_trees = renderer_shim_trees(project, trees) + for tree in shim_trees: + skill_dir = project / tree / STORIES_PROBE_SKILL + if not (skill_dir / "render.py").is_file(): + problems.append( + f"{tree}/{STORIES_PROBE_SKILL}/SKILL.md is a render shim but render.py " + f"is missing next to it — reinstall the BMad Method (bmm) module" + ) + if shim_trees: + if shutil.which("uv") is None: + problems.append( + "bmad-dev-auto now renders its workflow via `uv run render.py`; `uv` " + "was not found on PATH — install uv (https://docs.astral.sh/uv/) and " + "ensure it is on the login-shell PATH (sessions run in fresh panes)" + ) + if not (project / BMAD_DIR / "config.toml").is_file(): + problems.append( + f"bmad-dev-auto's render.py requires {BMAD_DIR}/config.toml (the " + f"post-#2285 central config) — re-run the BMAD installer to migrate " + f"this install" + ) + return problems + + def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> str: host = get_process_host() interp = host.hook_interpreter() @@ -370,6 +448,57 @@ def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None: exclude.write_text(prefix + "\n".join(new) + "\n", encoding="utf-8") +def _seed_bmad_tree(worktree: Path, repo_root: Path) -> list[str]: + """Merge-copy the main repo's ``_bmad/`` config surface into a worktree. + + The renderer-era bmad-dev-auto resolves the project root by walking UP from + the session cwd to the first ``_bmad/`` directory. Worktrees nest under + ``/.bmad-loop/runs/…``, so a checkout without its own ``_bmad/`` + (projects commonly gitignore it) silently adopts the MAIN checkout's: render + output and baked-absolute artifact paths then escape the worktree, and every + parallel session races on one shared ``_bmad/render/``. Even committed-_bmad + projects gitignore the ``*.user.toml``/``config.user.yaml`` layers, whose + absence hard-HALTs render.py when a referenced config key lives only there + (BMAD-METHOD #2588). + + Per-FILE merge (not per-dir): copy-when-absent, so a checkout that carries a + committed ``_bmad/`` keeps every tracked file untouched and only the + gitignored layers are filled in. Entries under ``BMAD_SEED_EXCLUDES`` (the + regenerated ``render/`` output) are never seeded. Same resolve-and-contain + guard as the seed_files loop so a symlink can't escape either tree. + + 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 an existing tree ("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 src in sorted(src_root.rglob("*")): + if not src.is_file(): + continue + rel = src.relative_to(src_root) + if rel.parts[0] in BMAD_SEED_EXCLUDES: + continue + 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 _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool: """Install the bundled bmad-loop-* skills into each project skill tree. @@ -455,8 +584,14 @@ def provision_worktree( seed_files are copied BEFORE the hook step so a seeded settings file that is 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 bmad-dev-auto walks up from the session cwd + to the first _bmad/ dir, so the worktree must carry its own. `_bmad/render/` + (session-regenerated) is additionally excluded unconditionally, so a project + that commits its _bmad/ never gets render output swept into a story commit. """ - 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() @@ -495,6 +630,10 @@ 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 (renderer-era bmad-dev-auto anchors its project + # root on it; user layers are gitignored even in committed-_bmad projects) + seeded_bmad = _seed_bmad_tree(worktree, repo_root) + # 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): @@ -544,9 +683,41 @@ def provision_worktree( patterns = {f"/{p.skill_tree}" for p in profiles} patterns |= {f"/{p.hooks.config_path}" for p in profiles} patterns |= {f"/{rel}" for rel in seeded} + patterns |= {f"/{rel}" for rel in seeded_bmad} + # always shielded, seeded or not: the session's render.py rewrites this dir + # during the run, AFTER provisioning — never ours to commit + patterns.add(f"/{BMAD_DIR}/render/") _worktree_local_exclude(worktree, sorted(patterns)) +def tracked_render_warning(project: Path) -> str | None: + """One-time migration hint for a project that already COMMITTED _bmad/render/**. + + Git excludes and .gitignore entries never affect tracked files, so the + renderer's delete-and-rewrite of that dir shows up as tracked modifications + that the unit's `git add -A` sweeps into story commits (with machine-absolute + paths baked in). Returns the warning text, or None when nothing is tracked + there. Best-effort — not a repo, or no git, means nothing to warn about. + """ + try: + proc = subprocess.run( # noqa: S603, S607 — fixed argv, no shell + ["git", "ls-files", "--", f"{BMAD_DIR}/render"], + cwd=project, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0 or not proc.stdout.strip(): + return None + return ( + f"{BMAD_DIR}/render/ is tracked by git; bmad-dev-auto's render.py rewrites " + f"it on every dev session, so story commits will sweep the churn — run " + f"`git rm -r --cached {BMAD_DIR}/render` once and gitignore it" + ) + + def _warn_if_policy_tracked(project: Path) -> None: """One-time migration hint: a .gitignore entry does not untrack an already-committed policy.toml, so repos initialized before the file was @@ -640,7 +811,14 @@ 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", + # bmad-dev-auto's render.py output: regenerated on every skill entry + # with machine-absolute paths baked in — never version it + "_bmad/render/", + ) if line not in have ] if to_add: diff --git a/tests/test_bmadconfig.py b/tests/test_bmadconfig.py index b71c1c16..ada0d538 100644 --- a/tests/test_bmadconfig.py +++ b/tests/test_bmadconfig.py @@ -89,3 +89,71 @@ 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 + + +# --------------- toml_artifact_parity (renderer-era desync warning) ----------- + + +def _parity_paths(root: Path) -> ProjectPaths: + return ProjectPaths( + project=root.resolve(), + implementation_artifacts=(root / "_bmad-output" / "implementation-artifacts").resolve(), + planning_artifacts=(root / "_bmad-output" / "planning-artifacts").resolve(), + ) + + +def _write_toml(root: Path, rel: str, impl: str) -> None: + path = root / "_bmad" / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'[modules.bmm]\nimplementation_artifacts = "{impl}"\n', encoding="utf-8") + + +def test_toml_parity_none_when_toml_surface_absent(tmp_path: Path) -> None: + """No _bmad/config.toml → the renderer preflight owns that report, not us.""" + assert bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) is None + + +def test_toml_parity_none_when_agreeing(tmp_path: Path) -> None: + _write_toml(tmp_path, "config.toml", "{project-root}/_bmad-output/implementation-artifacts") + assert bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) is None + + +def test_toml_parity_warns_on_override_desync(tmp_path: Path) -> None: + """A hand-added custom/config*.toml override moves the renderer's artifact + dir while the legacy yaml (which the orchestrator reads) stays put — the + exact desync the warning exists for.""" + _write_toml(tmp_path, "config.toml", "{project-root}/_bmad-output/implementation-artifacts") + _write_toml(tmp_path, "custom/config.user.toml", "{project-root}/elsewhere") + warning = bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) + assert warning is not None + assert "elsewhere" in warning and "deferred-work ledger" in warning + + +def test_toml_parity_bmm_beats_core_like_renderer(tmp_path: Path) -> None: + """render.py flattens [core] then [modules.bmm] (module wins on collision) — + the parity check must mirror that precedence, even across layers.""" + base = tmp_path / "_bmad" / "config.toml" + base.parent.mkdir(parents=True) + base.write_text( + "[modules.bmm]\n" + 'implementation_artifacts = "{project-root}/_bmad-output/implementation-artifacts"\n', + encoding="utf-8", + ) + # a LATER layer sets only [core]; bmm from the earlier layer still wins + user = tmp_path / "_bmad" / "config.user.toml" + user.write_text('[core]\nimplementation_artifacts = "{project-root}/core-dir"\n') + assert bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) is None + + +def test_toml_parity_none_when_unparseable(tmp_path: Path) -> None: + """An unparseable layer is the renderer preflight/probe's report (render.py + HALTs on it since #2588) — parity stays quiet rather than double-reporting.""" + _write_toml(tmp_path, "config.toml", "{project-root}/elsewhere") + (tmp_path / "_bmad" / "config.user.toml").write_text("not = [valid", encoding="utf-8") + assert bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) is None + + +def test_toml_parity_none_when_key_never_set(tmp_path: Path) -> None: + (tmp_path / "_bmad").mkdir() + (tmp_path / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + assert bmadconfig.toml_artifact_parity(tmp_path, _parity_paths(tmp_path)) is None diff --git a/tests/test_cli.py b/tests/test_cli.py index ee163e7f..7766c101 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1850,6 +1850,131 @@ def test_validate_stories_folder_known_selector_ok(project): assert cli._validate_stories_folder(project, STORIES_SPEC_FOLDER, selector="2") is None +# --------------- renderer-era bmad-dev-auto validate checks (#2587/#2588) ---- + + +def _install_renderer_env(project): + """Sandbox with a renderer-era bmad-dev-auto in the claude tree: shim SKILL.md + + render.py + the post-#2285 central config the renderer requires.""" + from conftest import install_base_skills + + install_bmad_config(project) + _write_policy(project.project) + install_base_skills(project) + skill = project.project / ".claude" / "skills" / "bmad-dev-auto" + (skill / "SKILL.md").write_text( + "Run `uv run {skill-root}/render.py` and follow its stdout.\n", encoding="utf-8" + ) + (skill / "render.py").write_text("# renderer\n", encoding="utf-8") + (project.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + return skill + + +def _validate_args(project, **kw): + kw.setdefault("render_probe", False) + return argparse.Namespace(project=str(project.project), spec=None, **kw) + + +def test_validate_renderer_checks_silent_on_pre_render_skill(project, capsys): + """A pre-render bmad-dev-auto gets neither renderer FAILs nor the ok-note — + validate output stays byte-stable for older bmm installs.""" + from conftest import install_base_skills + + install_bmad_config(project) + _write_policy(project.project) + install_base_skills(project) + + cli.cmd_validate(_validate_args(project)) + text = _validate_output(capsys) + assert "renderer preflight" not in text + assert "render probe" not in text + + +def test_validate_renderer_uv_missing_fails(project, capsys, monkeypatch): + """Shim installed but no uv on PATH: one loud preflight FAIL instead of a + result-less Stop per story (the shim HALTs before the workflow's HALT + protocol exists, so no spec status would ever be written).""" + import shutil as _shutil + + _install_renderer_env(project) + real_which = _shutil.which + monkeypatch.setattr(_shutil, "which", lambda c: None if c == "uv" else real_which(c)) + + assert cli.cmd_validate(_validate_args(project)) == 1 + text = _validate_output(capsys) + assert "`uv` was not found on path" in text + assert "renderer preflight ok" not in text + + +def test_validate_renderer_ok_note_when_shim_complete(project, capsys, monkeypatch): + import shutil as _shutil + + _install_renderer_env(project) + real_which = _shutil.which + monkeypatch.setattr(_shutil, "which", lambda c: "/usr/bin/uv" if c == "uv" else real_which(c)) + + cli.cmd_validate(_validate_args(project)) + assert "renderer preflight ok" in _validate_output(capsys) + + +def _fake_uv_on_path(tmp_path, monkeypatch, body: str): + import os + + from conftest import write_script_launcher + + bin_dir = tmp_path / "fake-bin" + bin_dir.mkdir() + write_script_launcher(bin_dir, "uv", body) + monkeypatch.setenv("PATH", str(bin_dir) + os.pathsep + os.environ.get("PATH", "")) + + +def test_validate_render_probe_ok(project, capsys, monkeypatch, tmp_path): + """--render-probe runs the real renderer via uv; the dispatch line on stdout + is the pass signal.""" + _install_renderer_env(project) + _fake_uv_on_path(tmp_path, monkeypatch, 'print("read and follow /r/workflow.md")\n') + + cli.cmd_validate(_validate_args(project, render_probe=True)) + assert "render probe ok" in _validate_output(capsys) + + +def test_validate_render_probe_reports_halt(project, capsys, monkeypatch, tmp_path): + """A render-time HALT (#2588: missing {{.var}} key, unparseable override + layer) fails validate with the HALT text — before a run burns a story.""" + _install_renderer_env(project) + _fake_uv_on_path( + tmp_path, + monkeypatch, + 'import sys\nprint("HALT and report to the user: config is missing ' + '`communication_language`")\nsys.exit(1)\n', + ) + + assert cli.cmd_validate(_validate_args(project, render_probe=True)) == 1 + text = _validate_output(capsys) + assert "did not produce its dispatch line" in text + assert "communication_language" in text + + +def test_validate_toml_parity_warning_surfaces(project, capsys, monkeypatch): + """A custom/config*.toml artifact override desyncs the renderer's TOML view + from the legacy yaml the orchestrator reads — validate warns (never FAILs).""" + import shutil as _shutil + + _install_renderer_env(project) + real_which = _shutil.which + monkeypatch.setattr(_shutil, "which", lambda c: "/usr/bin/uv" if c == "uv" else real_which(c)) + custom = project.project / "_bmad" / "custom" + custom.mkdir(parents=True, exist_ok=True) + (custom / "config.toml").write_text( + '[modules.bmm]\nimplementation_artifacts = "{project-root}/elsewhere"\n', + encoding="utf-8", + ) + + cli.cmd_validate(_validate_args(project)) + text = _validate_output(capsys) + assert "central toml config resolves implementation_artifacts" in text + + def test_dry_run_stories_shows_plan_halt_markers(project, capsys): """Item 10: dry-run mirrors the real dispatch's leg-1 markers for a pending spec_checkpoint story (`Halt after planning.` + BMAD_LOOP_PLAN_HALT).""" diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 6b1f5810..34babc23 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -162,6 +162,44 @@ def test_worktree_happy_path_merges_to_target(project): assert "worktree-opened" in kinds and "unit-merged" in kinds +def test_worktree_renderer_bmad_seed_and_render_residue_shielded(project): + """Renderer-era bmad-dev-auto end-to-end (BMAD-METHOD #2587): a + committed-_bmad project's worktree checkout carries the tracked config but + not the gitignored user layer — the _bmad merge-seed fills it in before the + session starts — and the render output the session writes into the worktree + (untracked, NOT gitignored upstream) never reaches the story commit.""" + (project.project / "_bmad").mkdir() + (project.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + (project.project / ".gitignore").write_text("_bmad/config.user.toml\n", encoding="utf-8") + commit_sprint(project, {"1-1-a": "ready-for-dev"}) # add -A commits _bmad + .gitignore too + (project.project / "_bmad" / "config.user.toml").write_text( + '[core]\ncommunication_language = "English"\n', encoding="utf-8" + ) + + seen = {} + + def dev(spec): + cwd = spec.cwd + seen["tracked_config"] = (cwd / "_bmad" / "config.toml").is_file() + seen["seeded_user_layer"] = (cwd / "_bmad" / "config.user.toml").is_file() + render = cwd / "_bmad" / "render" / "bmad-dev-auto" + render.mkdir(parents=True) + (render / "workflow.md").write_text("baked absolute paths\n", encoding="utf-8") + return wt_dev_effect(project, "1-1-a")(spec) + + engine, _ = make_engine(project, [dev, wt_review_effect(project, "1-1-a", clean=True)]) + summary = engine.run() + + assert summary.done == 1 and not summary.paused + assert seen["tracked_config"] # via the checkout (tracked) + assert seen["seeded_user_layer"] # via the _bmad merge-seed (gitignored in main) + assert "change for 1-1-a" in (project.project / "src.txt").read_text() + # neither the render residue nor the seeded user layer reached the target branch + merged = git(project.project, "log", "--name-only", "--format=", "main") + assert "_bmad/render" not in merged + assert "_bmad/config.user.toml" not in merged + + def test_worktree_run_dir_is_outside_worktree(project): commit_sprint(project, {"1-1-a": "ready-for-dev"}) engine, _ = make_engine( diff --git a/tests/test_install.py b/tests/test_install.py index 8a529338..0793852a 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -837,3 +837,221 @@ def test_copy_traversable_zip_source_copies_content(tmp_path): _copy_traversable(zipfile.Path(zf_path, "pkg/"), dst) assert (dst / "skill" / "SKILL.md").read_text() == "tool" + + +# --------------- _bmad seed + renderer preflight (BMAD-METHOD #2587/#2588) ---- + +RENDER_SHIM = "Run `uv run {skill-root}/render.py` and follow its stdout.\n" + + +def _install_bmad_surface(repo): + """Lay down a main-checkout _bmad/ config surface, including the pieces a + worktree checkout would lack (gitignored user layers) and a render/ output + dir that must never be seeded.""" + bmad = repo / "_bmad" + (bmad / "custom").mkdir(parents=True, exist_ok=True) + (bmad / "bmm").mkdir(parents=True, exist_ok=True) + (bmad / "scripts").mkdir(parents=True, exist_ok=True) + (bmad / "config.toml").write_text("[core]\n", encoding="utf-8") + (bmad / "config.user.toml").write_text( + '[core]\ncommunication_language = "English"\n', encoding="utf-8" + ) + (bmad / "custom" / "config.user.toml").write_text("[core]\n", encoding="utf-8") + (bmad / "bmm" / "config.yaml").write_text("implementation_artifacts: x\n", encoding="utf-8") + (bmad / "scripts" / "resolve_customization.py").write_text("# resolver\n", encoding="utf-8") + render = bmad / "render" / "bmad-dev-auto" + render.mkdir(parents=True, exist_ok=True) + (render / "workflow.md").write_text("MAIN-CHECKOUT ABSOLUTE PATHS\n", encoding="utf-8") + + +def _install_renderer_skill(root, tree=".claude/skills"): + """Upgrade the stubbed bmad-dev-auto to the renderer-era shim shape.""" + skill = root / tree / "bmad-dev-auto" + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text(RENDER_SHIM, encoding="utf-8") + (skill / "render.py").write_text("# renderer\n", encoding="utf-8") + + +def test_provision_worktree_seeds_bmad_when_absent(tmp_path): + """The renderer-era bmad-dev-auto anchors its project root on the first + _bmad/ dir walking UP from the session cwd — and worktrees nest inside the + project, so a checkout without one silently adopts the MAIN checkout's + (baking its absolute paths into the rendered workflow). The whole config + surface must be seeded, except the regenerated render/ output.""" + wt, repo = tmp_path / "wt", tmp_path / "repo" + repo.mkdir() + _install_bmad_surface(repo) + + provision_worktree(wt, [], repo) + + assert (wt / "_bmad" / "config.toml").is_file() + assert (wt / "_bmad" / "config.user.toml").is_file() # gitignored user layer + assert (wt / "_bmad" / "custom" / "config.user.toml").is_file() + assert (wt / "_bmad" / "bmm" / "config.yaml").is_file() # legacy yaml (pre-render skill) + assert (wt / "_bmad" / "scripts" / "resolve_customization.py").is_file() + # render/ output is regenerated per-checkout with absolute paths — never seeded + assert not (wt / "_bmad" / "render").exists() + + +def test_provision_worktree_bmad_merge_fills_only_missing_files(tmp_path): + """A committed-_bmad checkout keeps every file it carries (per-FILE merge, + copy-when-absent); only the gitignored layers absent from the checkout are + filled in.""" + wt, repo = tmp_path / "wt", tmp_path / "repo" + repo.mkdir() + _install_bmad_surface(repo) + committed = wt / "_bmad" / "config.toml" + committed.parent.mkdir(parents=True) + committed.write_text("COMMITTED", encoding="utf-8") + + provision_worktree(wt, [], repo) + + assert committed.read_text() == "COMMITTED" # tracked file untouched + assert (wt / "_bmad" / "config.user.toml").is_file() # missing layer filled in + + +def test_provision_worktree_bmad_seed_shielded_when_worktree_lacked_bmad(project, tmp_path): + """Gitignored-_bmad project: the worktree checkout has no _bmad at all, so the + whole seeded tree is ours — one anchored /_bmad exclude keeps the unit's + `git add -A` away from it.""" + repo = project.project + _install_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 "/_bmad/render/" in exclude + assert git(wt, "status", "--short", "--", "_bmad") == "" + + +def test_provision_worktree_bmad_merge_shields_only_seeded_files_when_committed(project, tmp_path): + """Committed-_bmad project: the checkout carries the tracked files, so only + the individually seeded (gitignored) layers are shielded — never a blanket + /_bmad that would mask real changes to tracked config.""" + repo = project.project + tracked = repo / "_bmad" / "config.toml" + tracked.parent.mkdir(parents=True) + tracked.write_text("[core]\n", encoding="utf-8") + git(repo, "add", "_bmad/config.toml") + git(repo, "commit", "-m", "commit bmad config") + (repo / "_bmad" / "config.user.toml").write_text("[core]\n", encoding="utf-8") + wt = tmp_path / "wt" + verify.worktree_add(repo, wt, "feat", "main") + assert (wt / "_bmad" / "config.toml").is_file() # tracked → in the checkout + + provision_worktree(wt, [get_profile("claude")], repo) + + exclude = (repo / ".git" / "info" / "exclude").read_text(encoding="utf-8").splitlines() + assert "/_bmad" not in exclude + assert "/_bmad/config.user.toml" in exclude + assert (wt / "_bmad" / "config.user.toml").is_file() + assert git(wt, "status", "--short", "--", "_bmad") == "" + + +def test_provision_worktree_render_exclude_always_written(project, tmp_path): + """/_bmad/render/ is shielded even when the repo has no _bmad at all — the + session's render.py writes it AFTER provisioning, so it is never ours to + commit regardless of the seeding path.""" + repo = project.project + 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/render/" in exclude + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks") +def test_provision_worktree_bmad_seed_rejects_symlink_escape(tmp_path): + """A symlink inside _bmad targeting outside the repo is skipped — the seed + never copies content from outside the project tree into the worktree.""" + wt, repo = tmp_path / "wt", tmp_path / "repo" + (repo / "_bmad").mkdir(parents=True) + (tmp_path / "outside.txt").write_text("SECRET", encoding="utf-8") + (repo / "_bmad" / "leak.toml").symlink_to(tmp_path / "outside.txt") + (repo / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + + provision_worktree(wt, [], repo) + + assert not (wt / "_bmad" / "leak.toml").exists() + assert (wt / "_bmad" / "config.toml").is_file() # honest neighbours still seeded + + +def test_missing_renderer_support_ignores_pre_render_skill(tmp_path, monkeypatch): + """A pre-render SKILL.md (no render.py mention) gets ZERO environment checks + — older bmm installs carry their config load inside the workflow.""" + import shutil as _shutil + + from bmad_loop.install import missing_renderer_support, renderer_shim_trees + + claude = get_profile("claude") + _install_base_skills(tmp_path, claude.skill_tree) + monkeypatch.setattr(_shutil, "which", lambda _cmd: None) # no uv anywhere + + assert renderer_shim_trees(tmp_path, [claude.skill_tree]) == [] + assert missing_renderer_support(tmp_path, [claude.skill_tree]) == [] + + +def test_missing_renderer_support_requirements_matrix(tmp_path, monkeypatch): + """Shim detected → render.py must sit next to it, uv must be on PATH, and the + post-#2285 central config must exist; each failure is reported once.""" + import shutil as _shutil + + from bmad_loop.install import missing_renderer_support + + claude = get_profile("claude") + tree = claude.skill_tree + _install_base_skills(tmp_path, tree) + skill = tmp_path / tree / "bmad-dev-auto" + (skill / "SKILL.md").write_text(RENDER_SHIM, encoding="utf-8") + monkeypatch.setattr(_shutil, "which", lambda _cmd: "/usr/bin/uv") + (tmp_path / "_bmad").mkdir() + (tmp_path / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + + # shim without render.py → the one skill-integrity problem + problems = missing_renderer_support(tmp_path, [tree]) + assert len(problems) == 1 and "render.py" in problems[0] and "reinstall" in problems[0] + + # complete shim + uv + config → all green + (skill / "render.py").write_text("# renderer\n", encoding="utf-8") + assert missing_renderer_support(tmp_path, [tree]) == [] + + # uv gone → reported exactly once even across two shim trees + codex_tree = get_profile("codex").skill_tree + _install_base_skills(tmp_path, codex_tree) + _install_renderer_skill(tmp_path, codex_tree) + monkeypatch.setattr(_shutil, "which", lambda _cmd: None) + problems = missing_renderer_support(tmp_path, [tree, codex_tree]) + assert len(problems) == 1 and "uv" in problems[0] + + # config.toml gone → reported (uv restored so it is the only problem) + monkeypatch.setattr(_shutil, "which", lambda _cmd: "/usr/bin/uv") + (tmp_path / "_bmad" / "config.toml").unlink() + problems = missing_renderer_support(tmp_path, [tree, codex_tree]) + assert len(problems) == 1 and "config.toml" in problems[0] + + +def test_tracked_render_warning(project, tmp_path): + """The one-time migration hint fires only when _bmad/render/** is actually + tracked — excludes and .gitignore can't shield tracked files from the unit's + `git add -A`.""" + from bmad_loop.install import tracked_render_warning + + assert tracked_render_warning(tmp_path) is None # not a repo → best-effort None + + repo = project.project + assert tracked_render_warning(repo) is None # repo, nothing tracked there + + rendered = repo / "_bmad" / "render" / "bmad-dev-auto" / "workflow.md" + rendered.parent.mkdir(parents=True) + rendered.write_text("rendered\n", encoding="utf-8") + assert tracked_render_warning(repo) is None # untracked residue → excludes handle it + + git(repo, "add", "_bmad/render") + git(repo, "commit", "-m", "commit render output") + warning = tracked_render_warning(repo) + assert warning is not None and "git rm -r --cached _bmad/render" in warning