Resolving the lane against the checkout the session is in โ
- Issue:
#974: bug(tooling): the PM lane guard resolves paths against the main checkout, so docs/projects is unreachable from any worktree - Date: 2026-08-27, the same day as the inversion.
- Status: fixed, with 23 new tests in
guard-pm-lane.test.jssection 16.
What was wrong? โ
The guard computed the lane from CLAUDE_PROJECT_DIR, which the harness sets to the MAIN checkout whatever worktree the session is in. โ
makeContext()took that one value as the repo root andclassifyPath()measured every path against it. A file inside a worktree therefore came out as.claude/worktrees/<lane>/docs/projects/..., which fails thedocs/projectsprefix test and additionally lands inside the blanket.claudeblock.- So a PM session in a worktree was default-denied on
docs/projects/**, the one directory the lane exists to permit, and its only route through was the build door, which is precisely the door that lane keeps shut.
The trigger is narrower than "worktrees are broken". โ
It needs the declared project dir and the write location to DISAGREE. Probing the live hook four ways, all under CLAUDE_PM_SESSION=1, separates the two:
| Case | Write target | CLAUDE_PROJECT_DIR | Old exit | Verdict |
|---|---|---|---|---|
| A | docs/projects in the worktree | the worktree | 0 | allowed, correct |
| B | docs/projects in the worktree | the main checkout | 2 | the bug |
| C | docs/projects in the main checkout | the main checkout | 0 | allowed, correct |
| D | apps/ in the worktree | the worktree | 2 | blocked, correct |
Case A is what proves the path logic itself was fine. The defect was the root it measured from.
The blast radius shrank hours after the issue was filed, and both readings are true. โ
- The report was accurate at about 0625, when the guard judged every session.
- The inversion (
#975) landed later that morning and exempts a dispatched subagent entirely, so a dispatched builder in a worktree never reaches the path logic at all. Do not read a passing dispatched agent as evidence the bug is gone. The session that can still hit it is a declared project-manager session whoseCLAUDE_PROJECT_DIRdisagrees with where it is working, which is exactly thepm-sessionworktree the lane table already calls out.
How does the guard resolve a path now? โ
The context carries every checkout a path could belong to, deepest first, and a path is judged against the innermost one that contains it. โ
makeContext()returnsrepoRootsinstead of a singlerepoRoot: the declared project dir, plus the checkout the session is actually standing in whengit rev-parse --show-toplevelfrom the cwd names a different one.classifyPath()asksenclosingCheckout()for the innermost containing root and takes its relative path from there.
Deepest-first is well defined, not a heuristic. โ
- If two roots both contain a path they are on one ancestor chain, so one is a prefix of the other and the longer string is the inner checkout. Sorting the roots by length descending and taking the first match is therefore exact.
Why that is right from a worktree. โ
- The worktree root is the inner checkout, so
<worktree>/docs/projects/xmeasures asdocs/projects/xand is allowed, while<worktree>/apps/web/xmeasures asapps/web/xand is still refused.docs/projectsmeans the same thing in every checkout, which is the property the lane always claimed and did not have.
Why that is right from the main checkout. โ
- Nothing moved. The main checkout is still a root, so every existing verdict is unchanged, and a path that climbs out of a worktree upward lands in the main checkout and is judged there:
<worktree>/../../../apps/web/xis refused asapps/web/x, and the same climb intodocs/projectsis allowed.
The same-repository test is what keeps this from being a widening. โ
- The cwd's checkout is adopted ONLY when it shares a git common dir with the declared one. A linked worktree's own git dir is
<main>/.git/worktrees/<name>but its common dir is<main>/.git, the same string the main checkout reports, so the test is exact and cheap. - Without it the guard would adopt whatever repository a cwd happened to sit in and start judging its files against a lane they have nothing to do with. That is the opposite error to the one being fixed, and it would reach her context home the day that becomes a checkout of something. Outside this repo the posture is default-allow and stays that way.
- The ordinary case, a session in the main checkout where the two agree, short-circuits on a string comparison and pays for no extra git calls.
How was it proven? โ
Test-first, watched red, then green. โ
- The four allowed-from-a-worktree cases failed against the old code and 19 controls in the same section passed, which is what shows the section is not simply green everywhere.
Tests 4 failed | 19 passed | 172 skipped (before the fix)
Tests 195 passed (195) (after)
Tests 624 passed (624) npm run test:toolingTwo mutations, disjoint red sets. โ
| Mutation | Red | Green | What it proves |
|---|---|---|---|
| Sort the roots shallowest-first | the 4 worktree allowances | 191 | the innermost-checkout rule is what fixes #974, not merely having two roots |
| Drop the same-repository test, adopting any cwd checkout | the unrelated-repo control | 194 | the guard does not widen into a repository that is not this one |
- Neither mutation crashed the suite, which is the control: 191 and 194 tests stayed green.
What the 23 tests cover. โ
docs/projectsinside a worktree via Write, Edit, a relative path, and agit commitpathspec, all with the declared dir naming the main checkout.- The same path from the main checkout, as the pairing control.
- Six blocked paths re-proven from inside a worktree (
apps/,tooling/,.claude/settings.json,.agents/,package.json,docs/engineering/), plus a blocked relative path and a blockedgit commitpathspec, plusapps/blocked when the declared dir IS the worktree (case D above). - Climbing out of the worktree in both directions.
- Her context home allowed from both checkouts,
~/.agentsoutside it still refused, auto-memory still allowed, the rest of~/.claudestill refused. - The over-adoption control, in both directions: an unrelated repo under the cwd is not adopted, and the declared checkout is still judged while the cwd sits in that unrelated repo.
What did NOT change? โ
- The lane. Not one path moved. This changes the root a path is measured from, never what is allowed.
- Who is judged. The
#975inversion stands: only a declared PM session, and never a dispatched subagent. lantern-fleet.sh. The issue checked it and cleared it. Apmorpm-*lane keeps the build door closed on purpose, and that is correct; the fix gives that lane a legitimate route todocs/projectsinstead of forcing it through the door.