The inversion: declare the PM, not the builder โ
- Status: shipped 2026-08-27 on
feat/admin-and-merchant-portals. Design decided, built test-first, mutation-checked, and probed live. - Issue:
#975: fix(tooling): the PM lane guard classifies by launch route, so every dispatched builder is treated as the PM - Parent: README.md, which documents the guard as originally built.
What is the change? โ
The guard judges only a session that DECLARES itself the project manager. Everything else builds with no ceremony. โ
- Operator, 2026-08-27: "A designated prompt sent to an agent by me or another agent is always a builder."
- Until 2026-08-27
.claude/hooks/guard-pm-lane.shwas deny-by-default for every session on the machine, andtooling/scripts/lantern-fleet.shwas the only thing that granted builder status, by tmux window name. - So the guard answered "did this session arrive through the fleet launcher", never "is this a builder".
The ratio was the defect. โ
- The design demanded a declaration from the MANY (every builder, arriving by several routes) and handed a free pass to the ONE (the PM session, whose launch is deliberate).
- There is exactly one PM session per day, so declaring THAT is reliable in a way that enumerating every builder route is not.
What is the PM declaration called? โ
CLAUDE_PM_SESSION=1. โ
Deliberately NOT a CLAUDE_ALLOW_* name, and that is the whole reason for the choice. โ
- Every existing guard variable in this repo (
CLAUDE_ALLOW_PR_READY,CLAUDE_ALLOW_BRANCH_OPS,CLAUDE_ALLOW_PM_BUILD) is a DOOR: setting it GRANTS a permission. - The declaration does the opposite. Setting it TAKES permission away.
- Giving it the
ALLOW_prefix would invite the exact reflex the never-bypass rule exists to stop: an agent meeting a refusal and reaching for the nearestCLAUDE_ALLOW_*variable. Here that reflex would be harmless in effect but corrosive in habit, and a reader skimming a settings file would misread which way the variable points. CLAUDE_PM_SESSIONsays what the session IS rather than what it may do, in the operator's own noun ("PM session"), and there is no reading of it under which setting it opens anything.
How does a dispatched subagent get through? โ
The harness already answers this, and its answer is better than any environment trick. โ
An Agent-tool subagent inherits its parent session's environment exactly, so a subagent dispatched BY a declared PM session would inherit the declaration and be blocked. Environment alone cannot separate them.
Every hook payload carries an optional
agent_id. From Claude Code's own hook schema (verified against the installed binary, version 2.1.243):"Subagent identifier. Present only when the hook fires from within a subagent (e.g., a tool called by an AgentTool worker). Absent for the main thread, even in
--agentsessions. Use this field (notagent_type) to distinguish subagent calls from main-thread calls."That is her rule already implemented by the platform: a subagent IS a designated prompt sent to an agent by another agent.
Use agent_id, never agent_type. โ
agent_typeis also set on the MAIN thread of a session started with--agent, so keying on it would exempt a PM session that merely booted with an agent definition. The schema says which field to use and this follows it.
What must the inversion preserve? โ
| Thing | Why it stays |
|---|---|
| The lane itself | Her context home ~/.agents/context/**, docs/projects/**, this session's Claude Code auto-memory, gh issue create and gh issue edit, and every read. A read is NEVER refused |
.agents/ blocked to the PM | Her words, 2026-08-24: "FUCK NO, FUCKING DELEGATE IT." |
| The existing door | CLAUDE_ALLOW_PM_BUILD=1 still opens a specific build on her direct word, and stays visible after the fact |
| The tokenizer | guard-pm-lane.mjs judges a path only in a write-target position, so naming a blocked path in a grep pattern, a commit message, or a builder prompt passes. Never regress that into grepping |
| Fail-open | A missing node, a parse error, or an unexpected exit code ALLOWS. It is a tripwire, not a jail, and a guard that blocks real work gets switched off |
This changes WHO is judged, never WHAT is allowed. โ
- The lane is not widened by one path.
What is the order of checks? โ
- Not declared as the PM, so a builder. Allow, before anything forks, so a builder session pays nothing for the hook.
- The call came from a subagent (
agent_idpresent). Allow. A dispatched worker is a builder whatever its parent is. - The door is open (
CLAUDE_ALLOW_PM_BUILD=1). Allow, on her word. - Otherwise, tokenize and judge against the lane, as today.
What says so out loud when a PM session runs UNDECLARED? โ
The inversion creates one new risk, and it must be visible rather than silent. โ
- A PM session started outside the fleet now carries no declaration and is therefore unguarded, which is the original 2026-08-24 failure with nothing in front of it.
A SessionStart check that reports which lane this session is in, every boot. โ
tooling/scripts/pm-lane-status.mjs, wired first into theSessionStartblock of.claude/settings.jsonbeside the four checks already there.
| State | What it prints |
|---|---|
| Declared PM | One line: this session is the PM and the lane is enforced |
Undeclared, and the tmux window name is pm or pm-* | A loud multi-line block: this is a PM session with no guard in front of it, plus the exact fix |
| Undeclared, anything else | One line: builder session, guard not watching |
A status line on every boot is not a warning on every boot. โ
- The
safe-automationskill's rule is that a false WARNING on a healthy boot trains the reader to ignore the line that has to carry the real event. A neutral one-line statement of which lane you are in is status, the same shape the four existing SessionStart hooks already print, and after the inversion it is the only thing that tells a session which side of the line it is on.
A PM session in a plain terminal outside tmux cannot be detected, and the doc says so rather than faking it. โ
- The tmux window name is the one honest signal available at session start. Being in the main checkout is not a signal: builder sessions run there too, including the one that wrote this file.
What else the inversion drags in โ
pm-migrate.sh needed the change README.md used to say it must never get, because the polarity flipped underneath that note. โ
- The old wording: it "needs no matching change, and must not get one: it only ever launches the
pmwindow, which stays closed." - That was correct while NON-pm windows carried the door. After the inversion the
pmwindow is the one that needs a stamp, and pm-migrate launches exactly that window, twice (new-windowandrespawn-window). - Left alone, a PM session repaired by pm-migrate would come back UNDECLARED and therefore unguarded on day one. That is the inversion applied consistently, not new scope. The README now carries the reversed note.
One matcher, shared, so the two launchers cannot drift. โ
- The
pm/pm-*name test lives inlib/fleet-claude-args.sh, which exists precisely because two hardcoded launch strings are a guarantee of drift. - Both launchers ask it about the window they are about to launch. pm-migrate always asks about
pm.
What does this change about the six open guard bugs? โ
- It changes what they MEAN. It does not close any of them, and none is touched here.
| Issue | What changes |
|---|---|
#974, #960 | Currently bite every session; afterwards only the declared PM session |
#969 | The false-positive half shrinks to the PM session. The false-negative half is untouched |
#938 | Reverses direction: running the fleet from inside a worktree misnames that worktree pm, which today over-blocks it and afterwards would fail to declare the real PM |
#957, #973 | Assessed at implementation time, once the code is in front of me |
How did this session get blocked? โ
By the bug itself, which is the cleanest evidence #975 has. โ
- This session was dispatched as a builder for task 7, into the main checkout, not through the fleet and not inside tmux (
TMUXunset,TMUX_PANEunset). - The guard refused its first write to
tooling/scripts/__tests__/guard-pm-lane.test.js. - The variable was NOT set to get past it. An agent opening its own door is the shape the guard exists to stop, and doing it while rewriting the guard would be worse.
What actually shipped? โ
Four commits, each pushed as it landed. โ
| Commit | What |
|---|---|
e3d206cb | This design, recorded before any code existed, from the session the guard had just blocked |
e16601e8 | The guard itself: the declaration gate and the subagent exemption, plus 30 new tests |
c907c08e | Both launchers, plus the shared matcher and 11 new tests |
0fccb92e | The session-start check, its 34 tests, and two test gaps mutation exposed |
206 tests across the two suites, 40 of them new, all written before the code. โ
What did the four probes show? โ
Run against the real hook, in the real repo, with CLAUDE_ALLOW_PM_BUILD cleared for every one. That last part matters: the session running them held the door, and inheriting it would have made all four pass for the wrong reason.
| Probe | Expected | Exit | Result |
|---|---|---|---|
Declared PM writes apps/web/src/App.jsx | refused | 2 | refused, naming the path and the door |
| Undeclared session writes the same path | allowed | 0 | allowed |
Declared PM writes docs/projects/pm-lane-guard/README.md | allowed | 0 | allowed |
Declared PM writes ~/.agents/context/lantern_app/my-voice.md | allowed | 0 | allowed |
Subagent (agent_id set) of a declared PM writes apps/web/src/App.jsx | allowed | 0 | allowed |
The subagent probe is at the payload level, and that is worth saying plainly. โ
- It feeds the hook the exact payload shape the harness sends for a subagent call rather than dispatching a live Agent-tool worker, because a subagent spawned from THIS session would have inherited this session's undeclared environment and proved nothing about the declared-PM case.
- The one assumption it rests on is that the harness really does set
agent_idon a subagent'sPreToolUsepayload, and that came from the harness's own hook schema rather than from memory: the shared payload builder returnsagent_idalongsidesession_idandcwd, andexecutePreToolHooksspreads that builder's result into thePreToolUsepayload.
What did building it turn up? โ
The session sent to fix this was blocked by it, on its first write. โ
- Dispatched as a builder into the main checkout, outside tmux, with no door. It stopped and asked rather than setting the variable, and the operator relaunched it with the door in the leading slot.
- That is the cleanest evidence
#975has, and it is why the design was written and committed before the relaunch: a lost resume would then have cost nothing.
Two mechanisms shadowed each other, so neither was tested. โ
- The declaration gate exists in the hook AND in the decider. Deleting either one left the whole suite green, because the other still caught it. A later cleanup could have removed one with no signal at all.
- Both are wanted. The hook's saves a node fork on every builder tool call, which is 49 ms down to 4 ms on nearly every call this machine makes. The decider's is the correctness gate, and one that lives only in the caller is not a gate.
- Fixed by testing the two properties separately: a stub
nodethat records whether it was called for the cost, and running the decider with no hook in front of it for the decision.
The session-start check exited 1 on a node this machine actually has. โ
- It first imported
PM_DECLARATIONfromguard-pm-lane.mjs, which reads better and cannot drift. There is a node v12.22.9 at/usr/bin/nodebeside the nvm v24 a normal session resolves; the guard module uses optional chaining, which v12 cannot parse, and a STATIC import fails at module load before anytry/catchin the importing file can run. - A session-start hook dying with a
SyntaxErroris the false-alarm-per-boot failure the check exists to avoid, and the noise would land on exactly the line that has to carry a real undeclared-PM warning later. - So the name is a local literal in old-parseable syntax, and the four copies are held together by a test instead. The guard itself is unaffected: its hook treats any exit code other than 2 as ALLOW, so an old runtime makes it fail open, which is its documented posture.
The declaration name is a literal in four files and a bash script cannot import a JS constant. โ
- A rename that missed one copy fails silently and in whichever direction is worse: the hook stops matching and the PM runs unguarded, or the launcher stamps a name nothing reads and every lane runs undeclared.
- A test now asserts each copy in the position that does the work, not merely somewhere in the file, and that no other
CLAUDE_PM_name appears in any of them.
What was proven load-bearing? โ
Every mechanism broken on purpose and watched go red, restored after each, with disjoint red sets.
| Mutation | Tests that went red |
|---|---|
| Remove the declaration gate from the hook | 2, including the one that pins the node fork |
| Remove the declaration gate from the decider | 1, the decider run standalone |
| Remove the subagent exemption | 5 |
Key the subagent check on agent_type instead of agent_id | 5, a different set, including the case that pins the difference |
Launcher declares pm only, not pm-* | 2 |
Launcher stamps a truthy value instead of exactly 1 | 10 |
pm-migrate stops declaring | 3 |
| Startup check stops warning on an undeclared PM | 12 |
| Startup check warns on every undeclared session | 7 |
What was deliberately NOT done? โ
The six other open bugs against this guard were left alone. โ
#960,#969,#973,#974,#938and#957. The inversion changes what several of them MEAN, and that is recorded above rather than acted on.
The lane was not widened by one path. โ
- This changed WHO is judged. Her context home,
docs/projects/**, auto-memory,gh issue createandgh issue edit, and every read are exactly where they were, and.agents/is still blocked to the PM.
One stale reference was left in place and is named here instead. โ
docs/projects/query-console/dispatch-prompt.mdtells a builder to start withCLAUDE_ALLOW_PM_BUILD=1 claude. That is now an unnecessary step rather than a wrong one: the door still works, it is simply no longer needed by a builder. Editing another project's dispatch prompt was outside this task.