Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ story <id>`, the same annotation a sweep bundle writes. Both sprint and stories

### Fixed

- **Refuse `isolation = "worktree"` combined with a `repo_root` override (#414).** The pair
previously produced a green preflight and then an isolated session with no dev primitive, no
result, and nothing journaled naming the cause. `validate` now reports it; `run`, `sweep`,
`resume` and the auto-triggered child sweep refuse to start; the dry-run banner names it first;
the TUI toasts it ahead of its clean-tree gate. Plumbing `project` through provisioning so both
work together is #443.

- **A configured path carrying `[`, `]`, `*` or `?` no longer makes git act on the wrong files
(#423).** `implementation_artifacts` reaches git verbatim out of the operator's
`_bmad/bmm/config.yaml`, and git reads a positional operand as a _pathspec_, not a path — so such
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,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 — plumbing both through provisioning is tracked as #443.

### Plugins

Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- 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 at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — 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` — in a private exclude scoped to that worktree alone (see below), never repo-wide.
- The git-add shield is scoped to the worktree and expires with it (#384). The provisioned tool files (skill trees, the per-CLI hook config, seeded configs) are excluded through a private `.git/worktrees/<id>/info/exclude`, activated by a worktree-scoped `core.excludesFile` — so the shield applies to that unit only, and `git worktree remove` deletes it along with the worktree. Storing that key is not the same as git reading it: config supplied through the environment (`GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, or a `git -c` above us) is **command** scope, which outranks **worktree** scope, so an ambient `core.excludesFile` of your own would leave a shield that reported success and never applied. After activating, bmad-loop asks git which excludes file it actually resolves; if that is not the one just written, the shield is skipped with a journaled reason rather than reported as working. The repository-wide `.git/info/exclude` is never written: it is shared with your own checkout and permanent, so shielding through it made every **new** file under a tracked tool dir (`.claude/skills`, `.claude/settings.json`) silently invisible to your `git add -A`, long after the run. Because that key shadows your own `core.excludesFile` (git reads it from the most specific scope and never concatenates), your excludes file is copied into the private one **byte for byte** when it is created — any encoding, since exclude patterns are paths and POSIX paths are arbitrary bytes — and any path, read NUL-terminated so leading or trailing whitespace in the filename survives. An excludes file that exists but **cannot be read**, or a **git that will not say which file applies** (a timeout, a failed spawn, or any answer to that one query other than a definite "there is no such key"), skips the shield with a journaled and notified reason instead of shadowing patterns it could not copy: not knowing whether there is anything to copy has the same standing as knowing there is and failing to read it. Only a definite **absent** answer is a silent no-op. The same rule governs the two `rev-parse` probes that identify the repository: only a failure of the first is the expected silent skip (you handed it a plain directory, or git is missing) — once git has answered that one, a fault on the second is journaled rather than swallowed. An **explicitly empty** `core.excludesFile` is not an unset one: git reads that as "no excludes file at all" and does **not** fall back to `$XDG_CONFIG_HOME/git/ignore`, so neither does the shield — patterns you deliberately switched off stay off instead of being copied into the private file and re-applied inside the worktree. Two runs against one repository are **serialized**: the probe → enable → activate → rollback sequence is taken under an exclusive lock, and a failed activation never rolls the repo-format flag back while another worktree's `config.worktree` still depends on it — otherwise one run's failure would silently switch off a sibling run's live shield. Three residues are worth knowing about: enabling this needs `extensions.worktreeConfig`, a permanent repo-format flag bmad-loop sets once and never removes — written at the last possible moment, so a degrade **above** it never leaves your repo marked for a shield that did not apply, and wherever it could be left set without a working shield — the enable failing, the activation failing, or the activation succeeding without taking effect — the flag is rolled back. It outlives a failed shield only where that rollback was declined because a sibling worktree depends on the flag, or could not be made at all, and the reason says which — the lock leaves a zero-length `.git/bmad-loop-shield.lock` behind (inside `.git`, so never in your working tree and never stageable), and lines an older bmad-loop already wrote into `.git/info/exclude` are **not** removed for you — delete them by hand. Where the flag cannot be set safely (`core.bare = true` or `core.worktree` in the shared config, which git requires you move first), the shield is skipped with a journaled reason rather than widened back — and a git that cannot **answer** those two questions is treated the same way rather than as a "no", since reading "git failed" as "that key is unset" is what would open the gate. It also needs **git 2.20 or newer** — the release that added both the flag and `git config --worktree`; on anything older (or a git that will not report its version) the shield is skipped the same way, and the repo-format flag is deliberately _not_ written, since git that old refuses a repository carrying it.
- 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; #443 lifts the restriction).
- `commit_message_template` (`{story_key}` / `{run_id}` substituted) customizes story/bundle commit messages.

### Plugins (extensibility)
Expand Down
49 changes: 49 additions & 0 deletions src/bmad_loop/bmadconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ 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.

**This function exists to be deleted.** The real fix is #443 — plumb ``project``
through provisioning for the non-git reads — and landing it removes this
function, all five of its call sites, the `policy.isolation-repo-root` id and
both doc sentences. It is a refusal rather than the fix because "which root
wins" is a separate decision per seeded surface (the relay only exists under
`project`; operator-configured `seed_files` may legitimately name a path outside
it), and `ProjectPaths.rebased` encodes `project == repo_root` besides. 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 until
#443 lands the orchestrator 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()

Expand Down
1 change: 1 addition & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"bmad-config",
"policy",
"policy.model-qualified",
"policy.isolation-repo-root",
"adapter.profile",
"adapter.binary",
"adapter.hookless",
Expand Down
Loading