Supersedes #414, and replaces the refusal that closed it in the short term.
#414 offered two fix shapes: (1) plumb paths.project into provision_worktree for the reads that mirror what init/validate touch, keeping repo_root for git operations; or (2) refuse the combination until (1) exists. The forward-port of the 0.9.1 hotfix took (2) — bmadconfig.worktree_isolation_conflict now refuses isolation = "worktree" under a repo_root override at validate, at run/sweep/resume, in the auto-triggered child sweep, in the dry-run banner and in the TUI's pre-launch guard. That was a deliberate scope decision, not a verdict on the design: shape (1) rewrites every disk read in provision_worktree, and it has an open design question shape (2) does not.
This issue is shape (1). Landing it deletes the refusal, and that deletion is part of the work — see "What this removes" below.
The open design question
bmadconfig.load_paths requires project/_bmad/bmm/config.yaml (bmadconfig.py:72-74), so the _bmad/ surface is under project by definition and repo_root/_bmad/ generally does not exist. But "which root wins" is not one answer — it is one answer per surface, and each has to be decided rather than defaulted:
- the hook relay is pointed at, never copied:
init writes project/.bmad-loop/bmad_loop_hook.py (install.py), and provisioning bakes an absolute path from repo_root into the worktree's hook config (worktree_flow.py:142-143). This one is unambiguous — the relay only exists under project.
- skill trees and
_bmad/custom/ overrides are copied from repo_root (worktree_flow.py:238-245, 262-266) and probed under project by missing_base_skills / resolve_review_layers. The preflight is the authority on which layers a run gets, so these follow project.
seed_files / seed_globs (worktree_flow.py:196-200) use repo_root as both the copy source and the is_relative_to containment base. These are operator-configured and may legitimately name paths outside project but inside the git root — this is the one where repo_root is arguably still right, and changing it is a behaviour change for existing monorepo users.
- the worktree destination: a worktree of
/mono carries app/, so anything resolving inside the new checkout needs project.relative_to(repo_root) applied. ProjectPaths.rebased (bmadconfig.py:43-63) currently sets project == repo_root == new_root and rebases the artifact dirs relative to the old project — it encodes the assumption that the two are equal, so it cannot express the divergent case at all and has to change with the rest.
What stays on repo_root
Everything that is genuinely a git operation, and this half should not move: workspace.py wholesale (worktree add/remove/prune, branch delete), merge-back and teardown in worktree_flow.py (389, 613, 730) and sweep.py:666, recovery_flow.py:172's workspace.root != self.paths.repo_root isolation predicate, engine.py's discard_worktree, and cli.py's clean-tree gates (1023, 1261), _reconcile_stale (179), --reverify (1813, 1855), the confirm commit (1984) and cmd_clean (2385).
Two pre-existing inconsistencies to settle while here
Both are visible only under a divergent pair, and both currently read a third thing — the raw --project dir — rather than either root:
cli.cmd_validate's verify.worktree_clean(project) (cli.py:268) and tui/app.py::_guarded's verify.worktree_clean(self.project) gate the wrong tree, while cmd_run/cmd_sweep gate paths.repo_root. Under an override, validate and the TUI check a directory that may not be a git root at all.
[verify] commands run in paths.project (verify.py:1815, 1847, 1887) but cli._reverify runs them in repo_root — the same commands, two cwds.
What this removes
Landing this should delete, not adapt:
bmadconfig.worktree_isolation_conflict and all five of its call sites (cli._reject_isolation_conflict at cmd_run/cmd_sweep/_resume_paused_run, the RuntimeError raise in _sweep_factory.factory, and the cmd_validate finding);
- the
policy.isolation-repo-root id from checks.VALIDATE_CHECKS;
- the
_warn_preflight_would_abort insert(0, conflict) leg and the extra paths parameter it needed;
- the
tui/app.py::_guarded refusal block and its except (BmadConfigError, PolicyError, OSError) fall-through;
- the mutual-exclusion sentences in
README.md (Worktree isolation section) and docs/FEATURES.md (Git worktree isolation section);
- the guard-dependent paragraph in
install.py's seed-completeness helper docstring, which currently justifies reading repo_root and paths.project as one by pointing at these guards;
- the tests that pin the refusal in
tests/test_bmadconfig.py, tests/test_cli.py and tests/test_tui_app.py.
worktree_isolation_conflict's docstring names this issue and says it exists to be deleted by it.
Coverage gap to close
No test drives provisioning with repo_root != project (tests/test_bmadconfig.py only asserts the key is parsed). The refusal's tests construct the divergent pair but stop at the refusal, so the seeding path itself has never run under it.
Supersedes #414, and replaces the refusal that closed it in the short term.
#414 offered two fix shapes: (1) plumb
paths.projectintoprovision_worktreefor the reads that mirror whatinit/validatetouch, keepingrepo_rootfor git operations; or (2) refuse the combination until (1) exists. The forward-port of the 0.9.1 hotfix took (2) —bmadconfig.worktree_isolation_conflictnow refusesisolation = "worktree"under arepo_rootoverride atvalidate, atrun/sweep/resume, in the auto-triggered child sweep, in the dry-run banner and in the TUI's pre-launch guard. That was a deliberate scope decision, not a verdict on the design: shape (1) rewrites every disk read inprovision_worktree, and it has an open design question shape (2) does not.This issue is shape (1). Landing it deletes the refusal, and that deletion is part of the work — see "What this removes" below.
The open design question
bmadconfig.load_pathsrequiresproject/_bmad/bmm/config.yaml(bmadconfig.py:72-74), so the_bmad/surface is underprojectby definition andrepo_root/_bmad/generally does not exist. But "which root wins" is not one answer — it is one answer per surface, and each has to be decided rather than defaulted:initwritesproject/.bmad-loop/bmad_loop_hook.py(install.py), and provisioning bakes an absolute path fromrepo_rootinto the worktree's hook config (worktree_flow.py:142-143). This one is unambiguous — the relay only exists underproject._bmad/custom/overrides are copied fromrepo_root(worktree_flow.py:238-245,262-266) and probed underprojectbymissing_base_skills/resolve_review_layers. The preflight is the authority on which layers a run gets, so these followproject.seed_files/seed_globs(worktree_flow.py:196-200) userepo_rootas both the copy source and theis_relative_tocontainment base. These are operator-configured and may legitimately name paths outsideprojectbut inside the git root — this is the one whererepo_rootis arguably still right, and changing it is a behaviour change for existing monorepo users./monocarriesapp/, so anything resolving inside the new checkout needsproject.relative_to(repo_root)applied.ProjectPaths.rebased(bmadconfig.py:43-63) currently setsproject == repo_root == new_rootand rebases the artifact dirs relative to the oldproject— it encodes the assumption that the two are equal, so it cannot express the divergent case at all and has to change with the rest.What stays on
repo_rootEverything that is genuinely a git operation, and this half should not move:
workspace.pywholesale (worktree add/remove/prune, branch delete), merge-back and teardown inworktree_flow.py(389, 613, 730) andsweep.py:666,recovery_flow.py:172'sworkspace.root != self.paths.repo_rootisolation predicate,engine.py'sdiscard_worktree, andcli.py's clean-tree gates (1023, 1261),_reconcile_stale(179),--reverify(1813, 1855), the confirm commit (1984) andcmd_clean(2385).Two pre-existing inconsistencies to settle while here
Both are visible only under a divergent pair, and both currently read a third thing — the raw
--projectdir — rather than either root:cli.cmd_validate'sverify.worktree_clean(project)(cli.py:268) andtui/app.py::_guarded'sverify.worktree_clean(self.project)gate the wrong tree, whilecmd_run/cmd_sweepgatepaths.repo_root. Under an override,validateand the TUI check a directory that may not be a git root at all.[verify] commandsrun inpaths.project(verify.py:1815,1847,1887) butcli._reverifyruns them inrepo_root— the same commands, two cwds.What this removes
Landing this should delete, not adapt:
bmadconfig.worktree_isolation_conflictand all five of its call sites (cli._reject_isolation_conflictatcmd_run/cmd_sweep/_resume_paused_run, theRuntimeErrorraise in_sweep_factory.factory, and thecmd_validatefinding);policy.isolation-repo-rootid fromchecks.VALIDATE_CHECKS;_warn_preflight_would_abortinsert(0, conflict)leg and the extrapathsparameter it needed;tui/app.py::_guardedrefusal block and itsexcept (BmadConfigError, PolicyError, OSError)fall-through;README.md(Worktree isolation section) anddocs/FEATURES.md(Git worktree isolation section);install.py's seed-completeness helper docstring, which currently justifies readingrepo_rootandpaths.projectas one by pointing at these guards;tests/test_bmadconfig.py,tests/test_cli.pyandtests/test_tui_app.py.worktree_isolation_conflict's docstring names this issue and says it exists to be deleted by it.Coverage gap to close
No test drives provisioning with
repo_root != project(tests/test_bmadconfig.pyonly asserts the key is parsed). The refusal's tests construct the divergent pair but stop at the refusal, so the seeding path itself has never run under it.