Skip to content

Resolving the lane against the checkout the session is in โ€‹

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 and classifyPath() measured every path against it. A file inside a worktree therefore came out as .claude/worktrees/<lane>/docs/projects/..., which fails the docs/projects prefix test and additionally lands inside the blanket .claude block.
  • 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:

CaseWrite targetCLAUDE_PROJECT_DIROld exitVerdict
Adocs/projects in the worktreethe worktree0allowed, correct
Bdocs/projects in the worktreethe main checkout2the bug
Cdocs/projects in the main checkoutthe main checkout0allowed, correct
Dapps/ in the worktreethe worktree2blocked, 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 whose CLAUDE_PROJECT_DIR disagrees with where it is working, which is exactly the pm-session worktree 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() returns repoRoots instead of a single repoRoot: the declared project dir, plus the checkout the session is actually standing in when git rev-parse --show-toplevel from the cwd names a different one.
  • classifyPath() asks enclosingCheckout() 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/x measures as docs/projects/x and is allowed, while <worktree>/apps/web/x measures as apps/web/x and is still refused. docs/projects means 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/x is refused as apps/web/x, and the same climb into docs/projects is 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:tooling

Two mutations, disjoint red sets. โ€‹

MutationRedGreenWhat it proves
Sort the roots shallowest-firstthe 4 worktree allowances191the innermost-checkout rule is what fixes #974, not merely having two roots
Drop the same-repository test, adopting any cwd checkoutthe unrelated-repo control194the 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/projects inside a worktree via Write, Edit, a relative path, and a git commit pathspec, 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 blocked git commit pathspec, plus apps/ 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, ~/.agents outside it still refused, auto-memory still allowed, the rest of ~/.claude still 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 #975 inversion stands: only a declared PM session, and never a dispatched subagent.
  • lantern-fleet.sh. The issue checked it and cleared it. A pm or pm-* lane keeps the build door closed on purpose, and that is correct; the fix gives that lane a legitimate route to docs/projects instead of forcing it through the door.

Built with VitePress