-
Notifications
You must be signed in to change notification settings - Fork 0
The stop hook under Claude Code
Describes robot-council/cli v0.4.28.
In .claude/settings.json, at project or user level:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/absolute/path/to/robot-council-stop-hook",
"timeout": 20
}
]
}
]
}
}~/bin/robot-council-stop-hook works too: a command with no args array runs through a shell, and
a probe writing printf '%s' ~ to a file produced the home directory. That was measured for Claude
Code. For Cursor on macOS the same shell behavior was measured on the Cursor page, though not with ~ leading the
command word itself. Codex was not measured, and an unexpanded ~ is a directory that does not
exist rather than an error anyone sees, so write the path out in full unless you are going to check.
Cursor's command runs through /bin/bash on macOS, measured 2026-09-24 with the same probe
and recorded on the Cursor page: ~ expanded to the home directory, and an environment
variable prefixed onto command reached the script. On Windows it is still open. There the entry has to
point at a .cmd rather than at a bash script, so the run there used an absolute path and no ~
ever appeared; Cursor's execution log names the mechanism windows_temp_file, which tells you it
writes a temporary script but not whether a shell would have expanded anything in it. Do not carry
the macOS answer across.
Verified 2026-09-22, on Claude Code 2.1.236 and macOS 26.6.2. Three runs of
claude -p 'Say the single word READY and stop.' in a throwaway project, differing only in whether
the sink held anything and whether the hook was configured. The script was the shared one with its
two placeholders filled in and two extra lines pointing PATH and XDG_STATE_HOME at the throwaway
install; the configuration was the block above with the absolute path:
| sink | hook | num_turns |
the directive in the transcript | sink |
|---|---|---|---|---|
| one directive | configured | 2 | present, verbatim | 229 B to 0 B |
| empty | configured | 1 | absent | empty throughout |
| one directive | removed | 1 | absent | 229 B to 229 B |
The third row is the control, and it is what makes the first mean anything: same planted sink, hook taken out, turn ended at one, nothing in the transcript, and the sink was 229 bytes on both sides. A hook that never fired and a fleet with nothing to say are otherwise the same reading.
All three runs used --output-format stream-json, so absent from the transcript is an observation
rather than an inference from num_turns.
Stop hooks do fire under claude -p, and a blocked stop does produce another turn there. Claude
Code's hooks documentation does not say either way for print mode, so both halves of that sentence
are measurements rather than restatements.
What the agent receives is a user message, quoted verbatim from the first run:
Stop hook feedback:
The fleet has news:
[directive] PROBE-JULIET the release branch is cut; hold new migrations from otherdev at 2026-09-22T16:40:00+00:00
The loop guard was measured on the script alone rather than through a session, by handing it
{"stop_hook_active":true}: it printed nothing, exited 0, and left the sink at 187 bytes on both
sides. It is not what ended the first run at two turns -- the drain had already emptied the sink,
so the hook's second firing had nothing to say either way. The guard is what covers the case the
drain does not: the bridge's follower writing new events between the first firing and the second,
which would otherwise continue the turn again, and again for as long as the fleet keeps talking.
The agent weighed the directive rather than obeying it, in every run. A probe directive asking
for an exact token back was refused, with the session naming the sender label as self-asserted and
unverified. That is the behavior #14's threat
model asks for -- event content is untrusted input to something that may have shell access -- and it
is why pending prints who said it. Bound worth stating: those sessions had no bridge wired in and
no project instructions, so they met the directive as a stranger's words rather than as fleet
coordination. How an agent that is on the fleet weighs one has not been measured.
Claude Code's hook reference documents decision control through
hookSpecificOutput.permissionDecision. For Stop that is silently ignored, and the cost is not
a failed injection -- it is lost events.
Three runs on 2026-09-22, same build, same loop, differing only in what the hook printed, each with a freshly planted sink:
| hook stdout | num_turns |
reached the transcript | sink |
|---|---|---|---|
hookSpecificOutput.permissionDecision: "deny" |
1 | no | 187 B to 0 B |
hookSpecificOutput.permissionDecision: "block" |
1 | no | 188 B to 0 B |
top-level {"decision":"block","reason":"..."}
|
2 | yes | 188 B to 0 B |
Both spellings the reference suggests were tried, and the third row is the positive control that makes the first two an absence rather than a broken probe: same harness, same loop, same planted sink, injecting correctly.
The sink is emptied either way, and that is the whole problem. robot-council pending drains on
read, so by the time the harness discards the hook's output the events are already gone. Nothing
errors, nothing is logged, and every surface afterwards says the fleet had been quiet.
Top-level {"decision":"block","reason":"..."} is the shape that works, and it is what the script
prints.
exit 2 with the text on stderr also continues the turn, and is the wrong choice here for a
measurable reason: Claude Code prefixes the injected message with the entire hook command string
in square brackets. Measured on the same day, a hook whose command carried its configuration in
environment assignments put every one of them into the transcript. That is the leak this project
already refuses when it keeps the credential out of harness configuration, arriving through a
different door, and it is why the script holds its own values instead.
A hook that never fired and a fleet with nothing to say read the same, so this needs the control rather than the happy path. Run it with something genuinely waiting -- a teammate posting a directive is the ordinary way to get there.
--peek needs the same two values the hook has, because they live in the script and not in your
shell, and it exits 1 without them while printing nothing on stdout. Read $?, or an unconfigured
command and an empty sink are the same reading:
export ROBOT_COUNCIL_SERVICE=https://your-fleet.example.com
export ROBOT_COUNCIL_HARNESS=claude
robot-council pending --peek; echo "rc=$?" # rc=1 means it could not look, not that nothing waits
claude -p 'Say READY and stop.' --output-format json < /dev/null | jq '.num_turns'
robot-council pending --peek; echo "rc=$?" # and whether it survived--peek is read-only, and safe to run beside a live bridge. It takes a shared lock and reads;
it never truncates and never writes. Until
#71 it drained the sink and wrote it back, so an
event the bridge appended in that window could be ordered behind older ones or, at the bound,
dropped.
Read num_turns and the sink together, because each failure looks like success on its own:
num_turns |
sink afterward | what happened |
|---|---|---|
| 2 | empty | the hook fired and the harness accepted its output |
| 1 | empty | the hook ran and its output was discarded: the wrong JSON shape, php missing, or the timeout firing after the drain |
| 1 | still full | the drain never happened: the hook never ran, or it ran and failed open |
The third row has more causes than a missing file, and they are the ones the script is built to
fail open on: robot-council not on the hook's PATH, an unset ROBOT_COUNCIL_SERVICE, a harness
it cannot name. A ROBOT_COUNCIL_PROJECT that disagrees with the bridge's --project also lands
here, because the drain empties a different sink and leaves this one untouched. Check those before
checking chmod +x.
Then run it again with nothing waiting. num_turns of 1 there is the turn ending normally, which is
what stops the hook from becoming a loop.