PM lane guard โ
- Status: in progress, opened 2026-08-24. The guard is built, registered, and live. Inverted on 2026-08-27 so it judges only a declared PM session; see inversion.md. Three review findings fixed on 2026-08-30, including a file mode that stopped it executing at all; see fixes-0830.md.
- Issue:
#957: infra: a PreToolUse guard that refuses any write outside the PM lane - Worktree resolution:
#974: bug(tooling): the PM lane guard resolves paths against the main checkout, so docs/projects is unreachable from any worktree, fixed 2026-08-27; see worktree-resolution.md. - Inversion:
#975: fix(tooling): the PM lane guard classifies by launch route, so every dispatched builder is treated as the PM - Launch plan: none. This is tooling and process enforcement, not product, so it maps to no ALPHA.md row and does not get a fake one.
What is this? โ
A PreToolUse guard that refuses any write outside the PM lane, always on, with one documented door. โ
- Wired on
Write|Edit|Bashin.claude/settings.json. The hook is.claude/hooks/guard-pm-lane.shand its decision logic istooling/scripts/guard-pm-lane.mjs. - Exit 2 blocks the tool call, exit 0 allows it, and anything unexpected allows.
It exists because a rule was written that morning and did not stop the next violation. โ
- The PM session wrote six commits nobody asked for on 2026-08-24, each time reading "this is broken" from the operator as an instruction to fix rather than an instruction to write a prompt.
- The rule in
.agents/rules/project/gated-permission.mdwas added hours before and did not help, because an agent about to act does not first think "this is a permission question". A rule cannot catch the case it is written for. A hook can. - Her words, 2026-08-24: "YOU ARE ONLY HERE TO HELP ORGANIZE TASKS UNLESS I STATE OTHERWISE."
What is the lane? โ
She drew it at 0855 on 2026-08-24. It is encoded exactly as stated.
| What | |
|---|---|
| ALLOWED | Her context home ~/.agents/context/** (the daily agenda, which the PM cannot work without), docs/projects/**, this session's own Claude Code auto-memory (~/.claude/projects/<sanitized-checkout>/memory/**, added for #959), gh issue create and gh issue edit, and every read |
| BLOCKED | Everything else in this repo: apps/, services/, packages/, tooling/, .github/, .agents/, .claude/, any config file, and docs/** other than docs/projects/**. Any git commit or git add whose paths fall in that set. Outside the repo: the rest of ~/.claude/** (settings, hooks, skills, another session's transcripts) and ~/.agents/** other than the context home above |
Inside the repo it is default-deny, not a blocklist. โ
- Anything that is not
docs/projects/**is refused. A new top-level directory is covered the day it appears rather than the day somebody remembers to add it to a list. - Outside the repo it is default-allow, so scratch files and other work keep functioning, with two exceptions:
~/.agents/**other than~/.agents/context/**, and~/.claude/**other than this session's own auto-memory (added for #959, see below). Agent configuration is a prompt like anything else, wherever it happens to live.
.agents/ is BLOCKED, and that was the expensive one. โ
- Her words: "FUCK NO, FUCKING DELEGATE IT." The PM argued to itself all day that config counts as organizing, and that loophole produced three of the six commits.
Auto-memory is allowed, and it is the one deliberately narrow exception. โ
#959: the blanket~/.claude/**block also caught Claude Code's own auto-memory at~/.claude/projects/<sanitized-checkout-path>/memory/**, so a PM session could hear an operator correction, be unable to record it, and lose it at session end, close to the opposite of what a PM session is for.- The carve-out matches the STRUCTURE
projects/<anything>/memory/**, never a computed or hardcoded sanitized name: that segment is generated by the harness from the MAIN checkout path and differs per machine (see CLAUDE.md). - Nothing else under
~/.claude/**moved. Settings, hooks, skills, and a session's own transcripts, which sit alongside memory in the same sanitized project directory, are exactly the user-level agent configuration this guard exists to keep a PM session out of.
Who gets judged? โ
Only a session that DECLARES itself the project manager, with CLAUDE_PM_SESSION=1. โ
- Everything else builds with no ceremony, whatever route it arrived by, an Agent-tool subagent included. Her rule, 2026-08-27: "A designated prompt sent to an agent by me or another agent is always a builder."
- It ran the other way round for three days. The full account is in inversion.md; the short version is that the guard was deny-by-default for every session and the launcher was the only thing granting builder status, so the question it really answered was "did this arrive through the fleet launcher".
The declaration is deliberately NOT a CLAUDE_ALLOW_* name. โ
- Every other guard variable here is a door that GRANTS a permission. This one takes permission away. Sharing that prefix would invite an agent meeting a refusal to reach for it, and would read backwards in a launcher.
- Only the exact string
1declares. A launcher stamping a truthy-looking value would leave the PM silently unguarded.
A dispatched subagent is exempt even under a declared PM. โ
- A subagent runs inside its parent's process and inherits the environment exactly, so the declaration cannot separate it from the PM that dispatched it. The payload can: the harness sets
agent_idonly inside an Agent-tool worker, and its hook schema names that field as the way to tell subagent calls from main-thread calls, explicitly in preference toagent_type.
The guard is ALWAYS ON for the session that declares itself, never session-scoped. โ
- Her words: "ALWAYS FUCKING ON!" A PM session cannot undeclare itself mid-flight any more than it could open the door for itself: Write and Edit see only the environment the session started with.
What is the door? โ
CLAUDE_ALLOW_PM_BUILD=1, the same contract as CLAUDE_ALLOW_BRANCH_OPS=1. โ
- Legitimate only on her direct, present-tense word for a specific object, and visible after the fact. A plan sentence, a priority call, or approval of the thing beside it is not that word.
| Form | Scope | Use |
|---|---|---|
CLAUDE_ALLOW_PM_BUILD=1 <command> | that one command segment | a single approved Bash call |
CLAUDE_ALLOW_PM_BUILD=1 claude | the whole session | a PM session she has told to build one specific thing, and the only route for the Write and Edit tools |
The door is for the PM only. A builder needs nothing. โ
- Before 2026-08-27 the second row was how a BUILDER session was started, because the guard judged everyone. It no longer is: an undeclared session builds with no ceremony, and dispatch prompts telling a builder to open the door are describing a step that is now a no-op.
Write and Edit can only be set at LAUNCH, so the launchers do it. โ
- Those two tools see only the environment the session was started with. Shell state does not persist between Bash calls, so a session can neither declare itself nor open the door for itself mid-flight, and the inline door form reaches Bash only.
- Both launchers therefore stamp the DECLARATION on a PM lane, and nothing at all on a builder lane. Before 2026-08-27 it was the reverse:
lantern fleetstamped the door on every lane that was not a PM lane, which is what made arriving through the fleet the only way to be treated as a builder.
A PM lane is matched by name, and pm-* counts. โ
- A PM session does not only run in the main checkout. There is a
pm-sessionworktree on this machine, and a bare "only the window calledpm" rule would leave it undeclared and therefore unguarded, which is the exact session the guard exists to hold. - Deliberately not a looser prefix. A builder lane called
pmx-laneis a builder lane, and over-blocking costs more here than the narrow rule does.
| Lane | Declaration | Why |
|---|---|---|
pm | stamped | the main checkout, where the session organizes work |
pm-session, any pm-* | stamped | a PM session running in a worktree is still a PM session |
| every other worktree | none | that is where building happens |
The matcher is SHARED by both launchers, which reverses the note that used to live here. โ
- This section previously said
pm-migrate.sh"needs no matching change, and must not get one", because it only ever launches thepmwindow and that window was the one thing needing nothing. - The polarity flipped and so did the conclusion.
pm-migratelaunches a PM session, so its window is now the one that needs a stamp, and a repaired PM without one would come back unguarded. - The
pm/pm-*test therefore lives in the sharedfleet-claude-args.sh, which exists because two hardcoded launch strings drifted once already and left the repairedpmwindow as the only lane unreachable from her phone.pm-migratepasses the literal namepmthrough that matcher rather than hardcoding the assignment, so the two cannot disagree about what a PM lane is.
The inline form is scoped to its own segment, which is stricter than the guards it was modelled on. โ
CLAUDE_ALLOW_PM_BUILD=1 true; rm -rf apps/webstill refuses. The override on one segment cannot open the one beside it.
What is the current state? โ
Built, registered, live, inverted, and proven in both directions. โ
- 228 tests in
tooling/scripts/__tests__/guard-pm-lane.test.jsand 34 inpm-lane-status.test.js, running undernpm run test:tooling. - Every mechanism was proven load-bearing by mutation, with disjoint red sets. The tables are in implementation.md, inversion.md and worktree-resolution.md.
- The strongest evidence is accidental, twice over. The guard blocked this project's own
git addon 2026-08-24, naming all four files, and the commit went through only after the door was opened explicitly. On 2026-08-27 it blocked the session sent to fix it, which is the cleanest demonstration#975has.
The lane is measured from the checkout the session is actually in, not from the declared project dir. โ
- The harness sets
CLAUDE_PROJECT_DIRto the MAIN checkout for hooks whatever worktree a session is in, so before#974a worktree file was measured as.claude/worktrees/<lane>/docs/projects/...and refused twice over. Apm-sessionworktree therefore had no route at all to its own lane. - The context now carries every checkout a path could belong to and judges against the innermost one containing it, so
docs/projectsmeans the same thing in every checkout. The full account, including why the cwd's checkout is adopted only when it shares a git common dir with the declared one, is in worktree-resolution.md.
A session start now says which lane it is in, before anything tries to write. โ
pm-lane-status.mjs, first in theSessionStartblock of.claude/settings.json. It 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, and an undeclared PM would otherwise now run unguarded and silently.
Three defects were found by a code review on 2026-08-28 and fixed on 2026-08-30. โ
- The hook was committed mode
100644, so on a fresh clone the guard she called always on never executed. The suite could not see it: every case spawnsbash HOOK, and bash given a script by path ignores its execute bit. - The tokenizer failed OPEN on brace expansion, allowing the braced form of a command it refused in plain form, and it cried wolf twice, on a trailing
# commentand ongit commit -m"msg". - All three now have cases in both directions. The before and after, with controls, is in fixes-0830.md, reproducible with
harness/probe-findings.mjs.
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.
What else is related? โ
#945: A text-scanning guard cannot tell content from a pattern that matches content. The defect class this guard was built to avoid, and it fired three more times on 2026-08-24.docs/planning/specs/2026-08-21_pr-ready-door_design.md. The PR-ready door proposal, whose heredoc defect is pinned shut here by a test before it could be inherited.#893: infra: duplicated guards need a mechanism, the written rule has now failed four times including once by its own author. The same shape one level up: a written rule is not a mechanism.#959: fix(tooling): the PM lane guard blocks auto-memory writes, so a PM session cannot record an operator correction. The blanket~/.claude/**block was one directory too wide; the fix narrows it toprojects/<anything>/memory/**and nothing more.#975: fix(tooling): the PM lane guard classifies by launch route, so every dispatched builder is treated as the PM. The inversion, written up in inversion.md. It shrinks the blast radius of#974,#960and half of#969to the PM session alone, and reverses the direction of#938, without closing any of them.- Agent workflow hierarchy, which maps the six layers agent guidance lives in and where a hook sits among them.