Skip to content

feat(cli,tui): refuse worktree isolation under a repo_root override (#433, 6E) - #445

Merged
pbean merged 4 commits into
mainfrom
feat/414-isolation-refusal
Aug 3, 2026
Merged

feat(cli,tui): refuse worktree isolation under a repo_root override (#433, 6E)#445
pbean merged 4 commits into
mainfrom
feat/414-isolation-refusal

Conversation

@pbean

@pbean pbean commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Sub-phase 6E of the 0.9.1 forward-port (#433): ports the #414 refusal, not the plumbing.

Worktree provisioning reads paths.repo_root for every surface it seeds off disk — the upstream skill trees, _bmad/ and the _bmad/custom/ overrides inside it, each seed_files/seed_globs entry — and bakes the hook relay's absolute path from it into the worktree's hook config. init, validate and the run preflight write and probe those same surfaces under paths.project. bmadconfig.load_paths requires project/_bmad/bmm/config.yaml, so _bmad/ is under project by definition and repo_root/_bmad/ generally does not exist at all.

The two are the same path by default, so the split is invisible — until _bmad/bmm/config.yaml sets repo_root, which is the documented monorepo knob. Then the preflight approves a surface the isolated run never receives, and the seed-completeness gates go inert rather than fire: the session dispatches into a worktree with no dev primitive, and stops with no result and nothing journaled naming the cause.

Why a refusal and not the fix

Deliberate scope call, carried over from the 0.9.1 line. Shape (1) — plumb project through provisioning — rewrites every disk read in provision_worktree, which is character-for-character the diff of two later sub-phases, and "which root wins" is a separate decision per seeded surface: the relay only exists under project, but operator-configured seed_files may legitimately name a path outside it and inside the git root. ProjectPaths.rebased encodes project == repo_root besides, so it cannot express the divergent case at all.

That work is filed as #443, which names every line this PR adds as something it deletes. worktree_isolation_conflict's docstring says so in the source, so a later reader does not mistake the refusal for the intended end state.

What lands

bmadconfig.worktree_isolation_conflict(paths, isolation) -> str | None is the sole producer of the text, so the five consumers cannot drift:

site disposition
cmd_run, cmd_sweep, _resume_paused_run _reject_isolation_conflict → rc 1
_sweep_factory.factory raises RuntimeError
cmd_validate report.fail("policy.isolation-repo-root", …), keeps going
_warn_preflight_would_abort problems.insert(0, …) — named first
tui/app.py::_guarded error toast, ahead of the clean-tree gate

Ordering is load-bearing in three places and each is pinned by a test:

  • ahead of the clean-tree and queue gates in cmd_run/cmd_sweep and in the TUI. This refusal says the configuration cannot run at all, so answering "commit or stash first" would send the operator to fix something that is not the problem. The fixtures leave the tree genuinely dirty so a wrongly-ordered gate speaks instead.
  • _sweep_factory raises rather than returns. By the time the engine calls the factory it has latched the trigger and journaled sweep-auto-trigger, and it reads a plain return as success — a bare decline would be recorded as sweep-auto-finished, i.e. a child sweep that ran and finished when none was ever launched. Raising lands on the sweep-auto-failed + notify path an unparseable policy.toml already takes.
  • validate reports rather than aborts, so its other gates still run.

_resume_paused_run gets it too: resume re-reads both files off disk, so a run started before the override was added must not finish its remaining stories through provisioning the preflight would now refuse. It is refused before the run-resume journal entry, so the journal does not record a resume that never happened.

Main-side adaptations (this is a re-implementation, not a cherry-pick)

  • _warn_preflight_would_abort keeps main's severity == "problem" filter. The 0.9.x version had none. Main additionally carries test_dry_run_banner_stays_silent_for_a_warning_only_finding, whose docstring calls the filter "a deliberate correction, not a port artefact" — so only the parameter changed (project: PathProjectPaths, at all three call sites) and the insert was added. Dropping the filter would have printed warnings as FAIL: lines.
  • Its docstring's claim that it "reads the same finding list _require_base_skills gates on" became narrower than the behaviour once the banner also reported the isolation conflict — which _require_base_skills does not check. Rewritten to name both gates it mirrors, and to say which refusals are deliberately not in the banner (dirty tree, queue, run id).
  • _findings_by_check does not exist on main; its equivalent is _validate_findings. _make_validate_pass takes policy=, not policy_text=. _render_findings was inlined in one TUI-render test on main and is now a helper.
  • The except (BmadConfigError, PolicyError, OSError) fall-through in the TUI guard depends on both loaders converting UnicodeDecodeError (a ValueError, not an OSError) into their own typed error. Verified present on main at policy.py:684 and bmadconfig.py:125 — they landed in 6C — and an ablation row proves the PolicyError leg is reachable, not merely present.
  • 0.9.x also has a paragraph in a seed-completeness helper in install.py citing these guards. Main has none of those helpers yet, so it has no home here; it is recorded on Plumb paths.project through worktree provisioning so repo_root != project is supported, and retire the refusal #443.

Verification

  • 3964 collected, 3936 passed, 24 skipped. That is main's 3953 + the 11 tests added here. The 4 reds are pre-existing test_module_skills_sync local skill-drift on the dev box and do not occur on a clean checkout.
  • uv run pyright 0 errors, 0 warnings, 0 informations — zero delta from main. checks.py is in the strict list and gained an id only, never logic.
  • trunk check --all --no-fix — 242 files, no issues.

Ablation matrix — every row against the whole suite

Collected total constant at 3964 on all 11 runs (no baseline drift); the full src/ digest was re-verified pristine after every row.

row edit ablated new reds which
A cmd_run refusal 1 test_start_refuses_…[run] — fails on assert REFUSAL in err, having got "git worktree is not clean": the later gate speaks, which is exactly the ordering claim
B cmd_sweep refusal 1 test_start_refuses_…[sweep] — no cross-bleed between the two params
C _resume_paused_run refusal 1 test_resume_refuses_… — fails engine constructed, i.e. resume proceeded to build
D _sweep_factory raise 1 test_auto_sweep_refuses_…DID NOT RAISE RuntimeError
E cmd_validate finding 1 test_validate_refuses_… — rc 0 where 1 was asserted
F dry-run banner insert 1 test_dry_run_banner_names_the_isolation_refusal_first — banner still prints, so this pins the isolation line, not banner silence
G TUI _guarded refusal 1 test_worktree_isolation_…_blocks_launch
G2 TUI refusal moved below the clean-tree gate 1 same test — the ordering claim, which G alone cannot pin
G3 except tuple narrowed to drop PolicyError 1 test_unreadable_policy_falls_through_… — uncaught PolicyError takes the app down
H .resolve() on both sides of the path compare 1 test_worktree_isolation_conflict_compares_normalized_paths
I the VALIDATE_CHECKS id 1 test_validate_refuses_… — the registry assert bites, so a future id cannot skip registration

Every row reddens exactly one test, inside the edit's blast radius, and no row reddened nothing.

Row H is the one that matters most for a refusal gate: a false positive here is worse than the bug it forecloses, since it would refuse an ordinary isolated project whose repo_root merely spells the same directory a different way. load_paths resolves both sides, but nothing obliges a hand-built ProjectPaths — or a future caller — to have done so.

Refs #433, #414. The plumbing is tracked as #443.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented worktree-isolated operations from starting when a different repository root is configured.
    • Added clear conflict reporting across validation, run, sweep, resume, dry-run, and TUI workflows.
    • Ensured auto-triggered sweeps fail safely instead of reporting successful completion.
  • Documentation

    • Documented that custom repository roots cannot be combined with worktree isolation.

t added 3 commits August 2, 2026 21:20
… 6E)

bmadconfig.worktree_isolation_conflict is the sole producer of the refusal
text; the four call sites land next. Registers policy.isolation-repo-root in
VALIDATE_CHECKS so cmd_validate can emit it.

Refs #433, #414. The plumbing that deletes this is #443.
…433, 6E)

Provisioning seeds every off-disk surface from repo_root while init, validate
and the run preflight probe project, so a project that set both got a green
preflight and an isolated session with no dev primitive — no result, and no
journal line naming the cause.

Five call sites, one message: _reject_isolation_conflict aborts cmd_run,
cmd_sweep and _resume_paused_run; _sweep_factory RAISES instead, so the parent
journals sweep-auto-failed rather than recording a child sweep that never ran;
cmd_validate reports it as policy.isolation-repo-root and keeps going;
_warn_preflight_would_abort names it first in the dry-run banner; the TUI
toasts it ahead of the clean-tree gate, since "commit or stash first" would
send the operator to fix something that is not the problem.

_warn_preflight_would_abort now takes ProjectPaths rather than project alone —
the conflict is a fact about the two roots' relationship. Its problem-severity
filter is main's and stays: the 0.9.x banner had none, and dropping it would
print warnings as FAIL lines.

Refs #433, #414. The plumbing that retires this is #443.
One line each in README and FEATURES, both on the existing repo_root sentence,
naming #443 as the work that lifts the restriction.

Refs #433, #414.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 857fcc72-fe65-4dcd-9755-7696d4bc970e

📥 Commits

Reviewing files that changed from the base of the PR and between a8e921a and 3eb37d7.

📒 Files selected for processing (1)
  • CHANGELOG.md

Walkthrough

The change rejects isolation = "worktree" when repo_root differs from project. Validation reports a structured finding. CLI commands, child sweeps, dry-runs, and TUI launches apply the conflict check.

Changes

Worktree isolation conflict

Layer / File(s) Summary
Conflict predicate and validation contract
src/bmad_loop/bmadconfig.py, src/bmad_loop/checks.py, tests/test_bmadconfig.py
Adds worktree_isolation_conflict and registers policy.isolation-repo-root. Equivalent paths and non-worktree isolation remain valid.
CLI validation and command preflight
src/bmad_loop/cli.py, tests/test_cli.py, CHANGELOG.md, README.md, docs/FEATURES.md
Validation and command preflight reject conflicting configuration. Dry-runs report the conflict, and child sweeps follow the failure path.
TUI launch guard
src/bmad_loop/tui/app.py, tests/test_tui_app.py
The TUI checks the conflict before dirty-worktree validation and blocks detached launch when the configuration conflicts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant bmadconfig
  participant CommandFlow
  CLI->>bmadconfig: Check project paths and isolation
  bmadconfig-->>CLI: Return conflict message or None
  CLI->>CommandFlow: Reject launch or continue preflight
  CommandFlow-->>CLI: Record child-sweep failure or render dry-run result
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: dracic

Poem

A rabbit checks the roots with care,
No mismatched worktrees enter there.
CLI and TUI guard the gate,
Dry-runs name the conflict state.
Child sweeps fail and logs stay clear.
Hop, hop—the paths align here! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main CLI and TUI change: refusing worktree isolation when repo_root is overridden.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/414-isolation-refusal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pbean

pbean commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: a8e921ae3d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pbean

pbean commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
CHANGELOG.md (1)

263-274: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten this entry to match the terse changelog style.

This entry runs 11 lines and explains internal mechanism details (why repo_root/_bmad/ doesn't exist, why the seed-completeness gates go inert). A changelog reader needs the user-facing behavior change, not the internal rationale already covered in the worktree_isolation_conflict docstring.

✏️ Proposed terser entry
-- **`isolation = "worktree"` under a `repo_root` override is refused instead of running empty
-  (`#414`).** Worktree provisioning seeds every off-disk surface from `repo_root` — skill trees,
-  `_bmad/` and the `_bmad/custom/` overrides in it, each `seed_files`/`seed_globs` entry — and bakes
-  the hook relay's absolute path from it, while `init`, `validate` and the run preflight write and
-  probe those same surfaces under `project`. `load_paths` requires `project/_bmad/bmm/config.yaml`,
-  so `repo_root/_bmad/` generally does not exist at all. A project that set both got a green
-  preflight and then an isolated session with no dev primitive, stopping with no result and nothing
-  journaled naming the cause; the seed-completeness gates went inert rather than fired. `validate`
-  now reports the pair, `run`/`sweep`/`resume` and the auto-triggered child sweep refuse to start,
-  the dry-run banner names it first, and the TUI toasts it ahead of its clean-tree gate. Plumbing
-  `project` through provisioning so both work together is `#443`.
+- **Refuse `isolation = "worktree"` combined with a `repo_root` override (`#414`).** The combination
+  previously produced a green preflight followed by an isolated session with no dev primitive and no
+  journaled cause. `validate` now reports the conflict; `run`, `sweep`, `resume`, and the
+  auto-triggered child sweep refuse to start; the dry-run banner names it first; the TUI shows it
+  before the clean-tree gate. Plumbing `project` through provisioning to support both together is
+  tracked as `#443`.

As per coding guidelines, "Write Unreleased changelog entries tersely, scannably, and imperatively."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 263 - 274, Shorten the changelog entry for the
worktree isolation and repo_root conflict to a concise, imperative statement
covering the user-facing behavior: the conflicting configuration is detected and
refused by validation, run-related commands, preflight, dry-run, and TUI flows.
Remove internal provisioning, path, seed, and failure-mechanism details, and
retain the issue reference.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 263-274: Shorten the changelog entry for the worktree isolation
and repo_root conflict to a concise, imperative statement covering the
user-facing behavior: the conflicting configuration is detected and refused by
validation, run-related commands, preflight, dry-run, and TUI flows. Remove
internal provisioning, path, seed, and failure-mechanism details, and retain the
issue reference.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7140f214-960f-4935-87fa-b87a3698b34f

📥 Commits

Reviewing files that changed from the base of the PR and between 29c147c and a8e921a.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • docs/FEATURES.md
  • src/bmad_loop/bmadconfig.py
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/tui/app.py
  • tests/test_bmadconfig.py
  • tests/test_cli.py
  • tests/test_tui_app.py

The mechanism it spelled out is already verbatim in
worktree_isolation_conflict's docstring; the entry keeps the behavior change
and all five surfaces.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@pbean

pbean commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Taking this one — applied in 3eb37d7, essentially your wording.

Worth saying why, since I declined the same-shaped finding on #441 two hours ago and do not want that to read as inconsistency. There I measured the section: the entries sat at the 64th and 14th percentile of the 42 ### Fixed entries, and the proposed 1–2 line form had no precedent anywhere in ## [Unreleased] (n = 61, minimum 3 lines) — so the proposal was out-of-band, not the entries.

This proposal is 6 lines, comfortably inside that distribution. So both versions were in-band, which stopped it being a measurable question and made it a judgement one — and on the merits you are right. What your version drops is the mechanism (why repo_root/_bmad/ does not exist, what load_paths requires, why the seed-completeness gates went inert), and in this PR that text is not merely paraphrased elsewhere, it is verbatim in worktree_isolation_conflict's docstring, which this same PR adds as the canonical explanation. Duplicating it in the changelog earns nothing and gives it a second place to drift.

Kept everything user-facing: the behaviour change, all five refusal surfaces, and the #443 pointer for the plumbing that retires this.

No other findings this round.

@pbean
pbean merged commit 90915aa into main Aug 3, 2026
10 checks passed
@pbean
pbean deleted the feat/414-isolation-refusal branch August 3, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant