PM lane guard: what to review โ
| Field | Value |
|---|---|
| Branch | feat/admin-and-merchant-portals, draft PR #976: feat(admin): portal navigation, financials and monitoring, plus the agent-process work behind them, not merged |
| Project | README.md |
| Issue | #957: infra: a PreToolUse guard that refuses any write outside the PM lane |
| Servers you need | None. There is nothing to click. This is a PreToolUse hook and a shell matcher |
| Local origins | Not 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.mjsruns first in theSessionStartblock, 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=1puts 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_DIRto 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. Apm-sessionworktree 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.shlaunches 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.mdholds 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.jsoninvokes 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 read100755; this one reads100644. - 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/foois blocked correctly, andrm -rf {tooling,apps}/foois allowed. - The intent was
/^(\s|$)/. It is not in the documented gap table inimplementation.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
# commentblocks on["#","scaffold","the","project"], andgit 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? โ
| Piece | Issue | State |
|---|---|---|
| The guard itself | #957: infra: a PreToolUse guard that refuses any write outside the PM lane | Built, 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 worktree | Fixed on branch |
| The inversion | #975: fix(tooling): the PM lane guard classifies by launch route, so every dispatched builder is treated as the PM | Fixed 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 correction | Fixed on branch |
| The defect class this guard was built to avoid | #945: A text-scanning guard cannot tell content from a pattern that matches content | Open, 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 author | Open |