Skip to content

PM lane guard โ€‹

What is this? โ€‹

A PreToolUse guard that refuses any write outside the PM lane, always on, with one documented door. โ€‹

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.md was 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
ALLOWEDHer 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
BLOCKEDEverything 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 1 declares. 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_id only 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 to agent_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.
FormScopeUse
CLAUDE_ALLOW_PM_BUILD=1 <command>that one command segmenta single approved Bash call
CLAUDE_ALLOW_PM_BUILD=1 claudethe whole sessiona 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 fleet stamped 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-session worktree on this machine, and a bare "only the window called pm" 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-lane is a builder lane, and over-blocking costs more here than the narrow rule does.
LaneDeclarationWhy
pmstampedthe main checkout, where the session organizes work
pm-session, any pm-*stampeda PM session running in a worktree is still a PM session
every other worktreenonethat 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 the pm window and that window was the one thing needing nothing.
  • The polarity flipped and so did the conclusion. pm-migrate launches 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 shared fleet-claude-args.sh, which exists because two hardcoded launch strings drifted once already and left the repaired pm window as the only lane unreachable from her phone. pm-migrate passes the literal name pm through 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/web still 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. โ€‹

The lane is measured from the checkout the session is actually in, not from the declared project dir. โ€‹

  • The harness sets CLAUDE_PROJECT_DIR to the MAIN checkout for hooks whatever worktree a session is in, so before #974 a worktree file was measured as .claude/worktrees/<lane>/docs/projects/... and refused twice over. A pm-session worktree 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/projects means 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 the SessionStart block 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 spawns bash 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 # comment and on git 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.

Built with VitePress