Skip to content

PM lane guard: what to review โ€‹

FieldValue
Branchfeat/admin-and-merchant-portals, draft PR #976: feat(admin): portal navigation, financials and monitoring, plus the agent-process work behind them, not merged
ProjectREADME.md
Issue#957: infra: a PreToolUse guard that refuses any write outside the PM lane
Servers you needNone. There is nothing to click. This is a PreToolUse hook and a shell matcher
Local originsNot applicable

Is there anything to look at? โ€‹

No screen, but there IS something you see, and it is the first line of every session. โ€‹

  • pm-lane-status.mjs runs first in the SessionStart block, so a session announces which lane it is in before anything tries to write.
  • Start a session in the main checkout and one in a worktree. The PM window should say it is guarded; a builder lane should say it is not.
  • That line exists because the inversion removed the old symptom: a session in the wrong lane used to announce itself the first time it tried to write.

What changed here? โ€‹

The guard was INVERTED on 2026-08-27. It now judges only a session that declares itself the PM. โ€‹

  • Before: deny-by-default for every session, with the fleet launcher as the only thing granting builder status. So the question it really answered was "did this arrive through the fleet launcher".
  • Now: only CLAUDE_PM_SESSION=1 puts a session in the lane. Your rule: "A designated prompt sent to an agent by me or another agent is always a builder."
  • The declaration is deliberately not a CLAUDE_ALLOW_* name. Every other guard variable grants a permission; this one takes one away, and sharing that prefix would invite an agent meeting a refusal to reach for it.

The lane is measured from the checkout the session is actually in. โ€‹

  • The harness sets CLAUDE_PROJECT_DIR to the MAIN checkout whatever worktree a session is in, so a worktree file used to be measured as .claude/worktrees/<lane>/docs/projects/... and refused twice over. A pm-session worktree had no route at all to its own lane.

Auto-memory is allowed, and it is the one deliberately narrow exception. โ€‹

  • The blanket ~/.claude/** block also caught Claude Code's own auto-memory, so a PM session could hear a correction from you, be unable to record it, and lose it at session end.
  • The carve-out matches the structure projects/<anything>/memory/**, never a computed or hardcoded sanitized name, because that segment differs per machine.

A PM lane is matched by name, and pm-* counts, through a matcher both launchers share. โ€‹

  • pm-migrate.sh launches a PM session, so its window needs the stamp too. The note that used to live in the README said the opposite, and the polarity flip reversed it.

What is deliberately NOT done? โ€‹

The known gaps are listed rather than quietly carried. โ€‹

  • implementation.md holds every shape the guard knowingly does not catch, and why each was left. The guard fails open by design and is a tripwire, not a jail.

guard-em-dash.sh itself was not changed, per the caution in #945: A text-scanning guard cannot tell content from a pattern that matches content. โ€‹

What open findings touch this project? โ€‹

Cross-referenced from ../merchant-and-venue-dashboards/branch-review-0828.md. The review's top two findings are both here, and the first one means the guard does not run at all on a fresh clone.

1. The guard cannot execute. .claude/hooks/guard-pm-lane.sh is committed mode 100644 โ€‹

  • All seven sibling hooks are 100755, and .claude/settings.json invokes it by path exactly as it invokes them.
  • On any fresh clone the new PM lane guard never runs, and the guard you specified as always on is silently absent. The guard's own logic is otherwise sound.
  • This outranks finding 2. A guard with a hole still runs; this one does not.
  • How to check it yourself, one command: git ls-files -s .claude/hooks/ | sort. Seven rows read 100755; this one reads 100644.
  • Open, not fixed.

2. The guard fails OPEN on brace expansion. guard-pm-lane.mjs:245 โ€‹

  • The brace check is /\s|$/.test(...), unanchored. $ matches at the end of every string, so the test is true for any input.
  • Verified by running the module against real hook payloads, with controls: rm -rf tooling/foo is blocked correctly, and rm -rf {tooling,apps}/foo is allowed.
  • The intent was /^(\s|$)/. It is not in the documented gap table in implementation.md, which lists interpreters, wrappers, archives and quoted command substitution but not brace expansion, and no test covers it.
  • Open, not fixed.

10. The same guard cries wolf in two ways. guard-pm-lane.mjs:284 and :314 โ€‹

  • A trailing # comment blocks on ["#","scaffold","the","project"], and git commit -m"..." blocks on a path that does not exist. Both verified by running it.
  • A gate that cries wolf gets switched off. Open, not fixed.

The reviewer named its own limit here, and it is worth knowing. โ€‹

  • guard-pm-lane.mjs (865 new lines) got a targeted pass, not a line-by-line read. The findings above came from probing specific behaviours, so treat the rest of that file as unreviewed.

What issue does each piece close? โ€‹

PieceIssueState
The guard itself#957: infra: a PreToolUse guard that refuses any write outside the PM laneBuilt, live
The worktree path resolution#974: bug(tooling): the PM lane guard resolves paths against the main checkout, so docs/projects is unreachable from any worktreeFixed on branch
The inversion#975: fix(tooling): the PM lane guard classifies by launch route, so every dispatched builder is treated as the PMFixed on branch
The auto-memory carve-out#959: fix(tooling): the PM lane guard blocks auto-memory writes, so a PM session cannot record an operator correctionFixed on branch
The defect class this guard was built to avoid#945: A text-scanning guard cannot tell content from a pattern that matches contentOpen, guard deliberately untouched
The same shape one level up#893: infra: duplicated guards need a mechanism, the written rule has now failed four times including once by its own authorOpen

Built with VitePress