Skip to main content
The gate guard is PM’s safety mechanism for the highest-stakes skip: writing source code before a reconcile gate has run. It is a PreToolUse hook that intercepts every Edit, Write, and NotebookEdit call — and a Bash call whose command text matches one of a closed, documented list of write shapes — and blocks them while the active epic has reconcileNeeded: true. This is the one place PM tolerates mechanical blocking over pure instruction: it protects the single highest-stakes skip, writing source before the reconcile gate actually runs.

How it works

Every Edit, Write, and NotebookEdit call is intercepted, and so is every Bash call whose command text resolves to one of the write shapes below. Before allowing the call to proceed, PM checks whether the currently active epic has reconcileNeeded: true — meaning the epic popped out of a detour and still owes a reconcile gate.
  • If reconcileNeeded is true: the tool call is blocked, with a message pointing you at the reconciler agent and naming the shape it matched when it matched one. Run the reconcile gate first.
  • If reconcileNeeded is false or absent: the tool call proceeds normally. Epics with no pending reconcile are completely unaffected.
  • If the active epic has been archived, nothing is blocked (0.31.0). state.active can legitimately name an archived epic for a stretch — the pointer is cleared on write paths like render, sync and commit-nudge, and this hook is read-only, so it can observe the window between. An epic that has ended owes no obligation, and a stale reconcileNeeded on one used to wedge writes with no CLI way out, since the block below is deliberately unreachable by set-gate-guard off.

The matcher is Bash|Edit|Write|NotebookEdit (0.46.0)

Through 0.45.0 the hook was registered for Edit|Write|NotebookEdit only, which made it one hop from useless: an agent blocked on Edit wrote the same file with cat > f <<EOF, sed -i, or tee, and the gate it exists to protect was skipped in one hop. The matcher now includes Bash, and for a Bash call the guard blocks only a member of the closed list below and passes everything else. What that added coverage can and cannot decide is set out under The Bash write shapes.

Always on for reconcile-owed epics

This check is always active for any epic carrying reconcileNeeded: true — including epics that already had the flag set before this behavior was introduced. It previously required an explicit set-gate-guard on; real-usage feedback showed that opt-in was never actually turned on across several sessions where it would have caught a real skip, so the default was flipped. There is no bypass for this case. Running set-gate-guard off does not silence the reconcile-owed block, Bash included. Any switch that silenced Bash writes while a reconcile is owed would be a bypass for the whole gate — an agent that learns it exists routes around the gate in one command. The only path through is completing the reconcile gate: run /pm:resume, let the reconciler agent complete the reconcile gate, and the block clears once record-reconcile has answered every detour the epic was paused for with --reconcile. Once cleared, file writes proceed as normal. Nor can an ordinary verb erase the obligation (0.44.0). Through 0.43.0 the render heal cleared reconcileNeeded on any epic that was archived or was not the active epic, so clear-active, set-active <other>, or archive-then-unarchive silently switched this block off, and a verdict against the wrong detour cleared it too. Now the obligation is recorded per detour on the paused epic’s may-invalidate link and survives all of those: moving the active pointer off an owing epic prints a warning naming the detours owed, and the block returns when the epic is active again. record-reconcile accepts a verdict only against an armed detour whose frame has been popped. The one clear the heal still makes is announced. An epic carrying reconcileNeeded: true with no detour frame pausing it and no armed or pre-0.44.0 may-invalidate link has nothing a verdict could ever be recorded against, so leaving the flag would block edits permanently. The engine cannot produce that state itself (a push always arms a link, and removing an armed one is refused); it comes from a hand-edited file or from the 0.44.0 stamp on an owing epic whose every link already carried a verdict. The heal clears it and says so on stderr:

The Bash write shapes — a closed list, and what it cannot see

The guard reads tool_name from the hook payload. Only an affirmatively-identified Bash call takes the shape path; anything else — Edit, an unknown tool name, an unparseable payload, an absent payload, or a Bash payload carrying no readable command — takes the ordinary blocking path unchanged. That direction is deliberate: defaulting an unidentifiable call to the allow path would be a silent hole in the one unconditional block PM ships. The list is closed, and each row matches a FIXED label the block message prints — never the matched path or any slice of the command, so the message interpolates no text the engine did not write. It blocks: Destroying the record is on the list, because it is not a smaller evasion than writing over a file: the guard is dormant while no record exists, so rm .conductor/state.json turns the whole block OFF, and the call after it passes. The record match is on the exact path, and that is what keeps PM’s own remedies runnable. It matches .conductor, .conductor/state.json, a /* glob of the directory, and a trailing * on either name (.conductor/state.json*, .conductor*) at any directory prefix, with one surrounding quote stripped from each end. It is never a longer literal filename beneath it, so rm .conductor/state.json.lock — the remedy the engine’s own lock refusal prints, as the literal path — stays runnable, and the lock-only glob .conductor/state.json.* (which cannot expand to the record) stays the runnable spelling for lock cleanup. state.json* IS blocked even though it would also reach the lock, and that costs nothing because PM prints the literal lock path, never a glob. Git is read through its subcommand. git rm -f .conductor/state.json destroys the record exactly as rm does, so a git invocation has its global options skipped first and its subcommand read. Six globals each take a separate value word — -C, -c, --git-dir, --work-tree, --namespace and --exec-path, in both the separate and the glued spelling — and every other flag-shaped word consumes itself alone, so git -p rm <record> is not read as rm being -p’s value. The subcommand’s OWN arguments are what the record pattern reads, never git’s globals, so git -C .conductor status (a read) passes, and the reader also repairs git -C <path> apply p.patch, this project’s own mandated spelling. Its bound, named rather than left to be found: git -C /repo/.conductor rm state.json passes, because state.json alone is not the record’s path. An invocation of PM’s own engine is never a command-word write shape. A node-led segment whose first argument is a path ending conductor.mjs or an unexpanded variable reference (node "$ENGINE" <verb> — the spelling PM’s own command docs emit), followed by a verb, is exempt from the command-word rows. That is what keeps the commands the gate names as its own exit — and the detour stack’s drop-detour — reachable as the list grows. The bound is exact: the exemption covers the command-word arm ONLY. A redirection in the same segment still blocks (node … conductor.mjs status > out.txt), because an exemption that swallowed redirections would make “prefix the command with an engine invocation” a one-line bypass. And no row is reachable from a node-led segment today anyway — the command-word arm reads the segment’s leading word, always the runtime — so the exemption is a forward commitment, not a behaviour you can observe. Segments are split on newline, ;, &&, || and | (except the >| no-clobber operator), and the scan runs per segment, so a write shape after a && is caught. The command word is taken after its last /, and a leading VAR=value, sudo, env or command is skipped.

The three fail-open modes, stated plainly

  • An unreadable record allows every Bash call CARRYING A COMMAND. This is a carve-out, not a gap: one remedy the unreadable-state message prints redirects into the record itself and would otherwise match the shape list.
  • An absent record leaves the guard dormant. With no .conductor/state.json the hook exits 0 silently for every tool. That is the plugin’s standing dormancy contract, and it is exactly why destroying the record is a shape.
  • A hook line the engine refuses fails open too — a hand-edited hooks.json line carrying a flag gate-guard does not declare exits 1, and Claude Code runs the tool call unguarded (see “A refused hook line fails OPEN, deliberately” above).

What it CANNOT see

A path built from a variable or $(…); anything behind eval; a script or Makefile target invoked by name; an interpreter given inline source (python -c, node -e); a program that writes files of its own accord; an in-place editor reached through another command’s arguments (find … -exec sed -i …, xargs … sed -i). It has no quoting model either, so a > inside a quoted string or a heredoc body reads as a redirection. The check is incomplete by construction, and the docs say so — every form it cannot decide stays where PM’s law puts it, in the instruction the block message carries. Accepted false positives. A redirect to a non-source file (node --test > red-1.txt, cmd > /tmp/scratch) is blocked: the guard has no path policy, and the window is short because running the gate is the correct next action anyway. A spaced comparison still blocks — awk '$1 > 5' f.txt, jq 'select(.n > 3)', rg -n 'a > b' ., git commit -m "fix: a > b" — and that is accepted under the no-quoting-model non-goal. The arrow case is not a false positive and is excluded deliberately, because this project mandates rg: a > whose immediately preceding character is - does not match, so rg 'foo->bar' src/ and git log --format='%h -> %s' pass.

Opt-OUT for the tracker-refresh case

A second check rides the same hook and behaves the opposite way: when the active epic owes a tracker refresh (it is linked to an external item and became active without that item being re-read), the editing tools are blocked only while set-gate-guard on. Turning the guard off silences this one and leaves the reconcile block above untouched. The escape hatch is deliberate and not optional. An agent that is offline, unauthenticated, or facing a deleted upstream item has to be able to proceed honestly — a --verdict unchanged recorded blind is a worse outcome than a bypass someone can see. Clear the obligation properly with record-tracker-refresh <id> --verdict unchanged|material-change --external-updated-at <iso>. Both halves are stated together so the asymmetry is a decision on the record rather than an inconsistency someone later “fixes”: the reconcile arm has no inverse, the tracker arm keeps one.

The set-gate-guard command

The command still exists and toggles the repo-level gateGuard flag in state.json:
The flag gates the tracker-refresh obligation — an epic whose linked item must be re-read before specs are drawn for it. It has no effect on the reconcile-owed block described above, which is unconditional.

Reading it back — set-gate-guard with no argument

New in 0.38.0. The toggle wrote and confirmed the write, and nothing anywhere read it back — so “is the guard on?” was answerable only by opening state.json, which is the thing a read verb exists to avoid.
The reader lives on the toggle, deliberately not on the gate-guard verb. That verb is the hook itself: it blocks by writing to stderr and exiting 2, and it allows by returning silently — so its stdout is protocol surface, and a human-readable report printed there would corrupt it. Silence from gate-guard is the allow signal, not a broken command. on alone does not mean anything is currently blocked. The guard also needs a live, non-archived active epic that owes one of the two obligations. The blocked line is computed from the active epic, so it reports BLOCKING NOW: '<id>' owes a reconcile even while the flag reads off, matching the unconditional reconcile case above. Reading gateGuard: true plus a silent hook and concluding the guard was broken is exactly the inference this removes.

The command line (0.44.0)

A help token never toggles the guard. set-gate-guard off --help prints set-gate-guard’s help, exits 0 and leaves the guard as it was; from 0.41.0 through 0.43.0 it exited 0 having set gateGuard: false. The same holds for -h, and for a help token anywhere after the verb. set-gate-guard reads one positional, on or off. A second one, or a flag it does not declare, is refused before anything is written. --force is accepted (it is a mutating verb) and is never read as the positional: set-gate-guard --force prints the same report as bare set-gate-guard. The gate-guard hook verb declares --platform, which hooks/hooks.json passes as --platform claude-code, and refuses a valueless or unknown one. Any other flag on its line is refused. A refused hook line fails OPEN, deliberately. In a repository that has run /pm:init, a gate-guard line carrying a flag it does not declare exits 1, after draining the hook payload on stdin:
Claude Code treats exit 1 as a non-blocking hook error: the error is shown and the tool call proceeds unguarded. Exit 2 would block every Edit/Write/NotebookEdit in every session until the plugin was fixed, which is the worse failure. Within one plugin version this needs PM’s own hooks/hooks.json to disagree with its own engine, and the test suite asserts every hook line in that file passes the check. Two ways remain: a hook line edited by hand, and an installed plugin’s hooks.json driving a checkout engine of a different version under PM_ENGINE_DELEGATION. So do not hand-edit hooks/hooks.json; if the guard’s hook errors on every tool call, update the plugin so its hook file and engine match. In a repository without PM the hook verbs refuse nothing and stay silent, as they always have. That is the opposite polarity from an unreadable state.json, which fails closed with exit 2 (below). The difference is what cannot be read: a refused command line is PM’s own hook file out of step with its engine, while an unreadable state file means whether a reconcile is owed is unknown.

An unreadable state.json blocks — fails CLOSED (0.44.0)

If .conductor/state.json exists but cannot be read — a merge left conflict markers in it, it is truncated, or its shape is wrong (not a JSON object, epics present and not an array or holding a non-object, detourStack present and not an array) — the guard blocks every Edit/Write/NotebookEdit and every Bash write shape with exit 2 until the file is fixed — with one carve-out that keeps the remedies below runnable: an affirmed Bash call carrying command text is ALLOWED whatever its shape, because one of the remedies the message prints redirects into the record itself (git show <rev>:.conductor/state.json > .conductor/state.json) and another is a mv of it, so a shape check here would block the escape hatch the message hands you. A Bash payload with NO readable command does not inherit it and blocks; there is nothing to decide from, and every remedy named is a command. Before this, the same file with a conflict marker prepended exited 0 and silently disabled the unconditional reconcile block, exactly when the record saying whether one is owed could not be read; with epics: {} the hook crashed with a TypeError (exit 1, which Claude Code also treats as allow). The hook’s output with a conflict marker at the top of the file:
This is not a wedge, and the reason is the carve-out, not the matcher (0.46.0). Through 0.45.0 what kept Bash open was hooks/hooks.json, which registered gate-guard for Edit|Write|NotebookEdit only. Under the widened matcher that is no longer true, and wedge-freedom rests instead on the exemption above — an affirmed Bash call carrying a command is allowed even over an unreadable record. Every remedy the message names is a shell command, so fix the file from Bash:
  • a merge left conflict markers → git checkout --ours .conductor/state.json (or --theirs);
  • the markers were committed → git show <good-rev>:.conductor/state.json > .conductor/state.json;
  • local damage → git restore .conductor/state.json;
  • git has never had the file (a repo damaged before its first commit) → mv .conductor/state.json .conductor/state.json.damaged, then /pm:init. init itself refuses while the damaged file is in place, and the move keeps its bytes beside the new one.
Do not allow an Edit/Write aimed at state.json itself as a way out — that was considered and declined: it would widen the one unconditional block with a path match on tool input, and every remedy above is already reachable through Bash. An ABSENT state.json is still dormancy: the hook exits 0 silently in a repo that has not run /pm:init.
If you see “blocked by gate guard” on an Edit, Write, NotebookEdit, or Bash call, the resolution is to run /pm:resume and complete the reconcile gate. set-gate-guard off will not help here.
The gate guard hook is dormant until /pm:init has been run in a project. It activates only in projects that have been initialized and have a .conductor/state.json present — and an absent or unreadable record is one of the fail-open modes documented above.