Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <adapter>`) now
registers itself with no config step: before every selection, core imports each module
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run_id>[/<story>]` 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/<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`.
- 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.
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -223,6 +228,17 @@ bmad-loop validate --project <project-root>
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.

Expand Down
76 changes: 76 additions & 0 deletions src/bmad_loop/bmadconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import tomllib
from dataclasses import dataclass, field
from pathlib import Path

Expand Down Expand Up @@ -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"
Expand Down
77 changes: 77 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>/_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] = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading