A rule persuades. A hook runs. Module 09 taught the exit-code contract that makes the difference: a hook is a shell command Claude Code runs at a lifecycle event, and on most events its exit code and its JSON on standard output are read as a decision.
This module uses that contract for two jobs the agents keep failing at on their own. The first is remembering the open questions: a list shown once at session start is forgotten twenty turns later, so a question has to arrive in the turn that touches its subject. The second is filing what a round learned: an agent that changed files and wrote nothing down needs one nudge, once, at the end of the turn.
Three facts govern both hooks, and getting any one wrong turns a helper into a hazard. A bookkeeping hook fails open, always: if it cannot read its own inputs, it lets the turn proceed, because the cost of a missed reminder is a reminder. A hook that blocks has to know how not to loop, because the same event that lets it block fires again on the continuation it caused. And context injected at the moment it is needed beats a list shown once, which is why one of these hooks fires on two different events with two different rules.
flowchart TD start["SessionStart<br/>inject blocking questions<br/>(unless source is compact)"] prompt["UserPromptSubmit<br/>inject only trigger-matched questions"] work["the turn runs"] stop["Stop<br/>read the round from the last byte offset;<br/>changed files and nothing filed? block once"] cont["Stop again, stop_hook_active true<br/>let the turn end"] start --> prompt --> work --> stop --> cont
Both hooks are below, in the regions where the arguments live. Start with open-questions.mjs, at its compact guard and its trigger loop, then enforce-capture.mjs at the four constraints inside main, then the one file that says where the blocking hook is allowed to live.
tools/hooks/open-questions.mjs One script, two events. SessionStart injects the blocking questions once; UserPromptSubmit injects only what the prompt touches.
/**
* Two events, one script: put the open questions in front of the model at the
* moment they matter.
*
* `SessionStart` injects the blocking questions, once, when a session begins.
* `UserPromptSubmit` injects a question only when the prompt uses one of its
* trigger words, on every prompt, for the life of the session. A list shown
* once at startup is forgotten twenty turns later; a question that arrives in
* the turn that touches its subject is read.
*
* This hook is bookkeeping. It never blocks, it never decides, and every
* failure path exits 0 having printed nothing on stdout. See
* docs/decisions/0137.
*
* Reads: knowledge-base/_questions.md. Writes: nothing.
*/
// ... imports; REPO_ROOT and KB_DIR resolved from import.meta.url, never cwd ...
/**
* The trigger table. This is data, and it is yours to edit, add a row when you
* file a question people keep re-asking. See docs/decisions/0142.
*
* `when` is a list of lower-cased phrases matched against the prompt as plain
* substrings; no regex, so a phrase with a dot or a bracket in it means the dot
* or the bracket. `question` is a substring of the question's line in
* `_questions.md`, which is what ties the row to the question rather than
* copying it: the file stays the one place the text lives, and a row whose
* `question` matches nothing quietly injects nothing.
*/
const TRIGGERS = [
{ when: ["claude_config_dir", "config directory"], question: "CLAUDE_CONFIG_DIR" },
{ when: ["concurrent", "at the same time", "two agents"], question: "two or more SDK subprocesses" },
];
/** Sections of `_questions.md` a trigger match is allowed to draw from. */
const TRIGGERABLE_SECTIONS = ["blocking", "open"];
// ... main() reads stdin, parses the payload, and routes on hook_event_name ...
function main() {
let data;
try {
data = JSON.parse(readStdinSync());
} catch (error) {
// Fail open. A hook that cannot read its own input has nothing to say, and
// saying it on stdout would corrupt the JSON channel for no gain.
return bail("could not parse the hook payload", error);
}
const event = data?.hook_event_name;
if (event === "SessionStart") return sessionStart(data);
if (event === "UserPromptSubmit") return userPromptSubmit(data);
return bail(`not wired for ${event ?? "an unnamed event"}`);
}
/** SessionStart: the blocking questions, once per session. */
function sessionStart(data) {
// `source` is one of startup, resume, clear, compact, fork. Compaction starts
// a "session" too, so a naive injector re-injects its whole payload after
// every compaction, and the context it was saving is the context it spends.
// The same guard can be written as a matcher group per source in the config
// ("matcher": "startup|resume|clear|fork"), which keeps the process from
// spawning at all; it is read here instead so that one handler entry covers
// every source and this rule stays visible next to the reason for it. See docs/decisions/0144.
if (data?.source === "compact") return;
const questions = read(KB_DIR);
if (!questions) return;
const blocking = questions.blocking ?? [];
if (blocking.length === 0) return; // Nothing blocking is not a message worth sending.
emit("SessionStart", [
"Blocking questions from knowledge-base/_questions.md, work is stopped until each is answered:",
...blocking.map((line) => `- ${line}`),
].join("\n"));
}
/** UserPromptSubmit: only the questions this prompt just walked into. */
function userPromptSubmit(data) {
const prompt = typeof data?.prompt === "string" ? data.prompt.toLowerCase() : "";
if (prompt === "") return;
const questions = read(KB_DIR);
if (!questions) return;
const pool = TRIGGERABLE_SECTIONS.flatMap((section) => questions[section] ?? []);
const matched = [];
for (const row of TRIGGERS) {
if (!row.when.some((phrase) => prompt.includes(phrase.toLowerCase()))) continue;
for (const line of pool) {
if (line.includes(row.question) && !matched.includes(line)) matched.push(line);
}
}
if (matched.length === 0) return; // No trigger, no injection. Silence is the common case.
emit("UserPromptSubmit", [
"Open questions this prompt touches, from knowledge-base/_questions.md:",
...matched.map((line) => `- ${line}`),
].join("\n"));
}
/**
* Print one `additionalContext` payload for the event that actually fired.
*
* `hookEventName` must name the event this run was invoked for. A SessionStart
* payload emitted from a UserPromptSubmit run is not an error anywhere: the
* harness reads the name, sees it does not match the event in flight, discards
* the whole object, and says nothing. The hook runs, exits 0, prints what looks
* like a correct payload, and injects nothing, which is why the name is passed
* in from the caller here rather than defaulted, and why both call sites are
* one line below the branch that knows which event it is in.
*/
function emit(hookEventName, additionalContext) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext },
}));
}
// ... read() parses _questions.md into sections; bail() writes stderr only ... tools/hooks/enforce-capture.mjs The Stop hook. The four constraints that keep a blocking hook from becoming a loop or a nag, all inside main.
/**
* Stop: refuse to end a turn that changed files and filed nothing.
*
* This is the one hook here that blocks, and a hook that blocks has four things
* to get right or it becomes either a loop or a nag:
*
* 1. It never blocks a continuation it caused itself (`stop_hook_active`).
* 2. It examines a round exactly once, by remembering how far into the
* transcript it has already read (a byte offset in a sidecar file).
* 3. It fails open on every error. A bookkeeping hook that cannot read its own
* inputs lets the turn end; the cost of a missed reminder is a reminder.
* 4. It says nothing when nothing changed.
*
* "Was anything filed" is answered from file modification times under
* knowledge-base/, not from the transcript, because a heredoc write, a
* subagent's write and an editor's write are all invisible to tool-use
* inspection and all count as filing. See docs/decisions/0138 through 0140.
*
* Declared in .claude/settings.json, deliberately not in the plugin manifest:
* docs/decisions/0141.
*/
// ... imports; WRITING_TOOLS is Write/Edit/MultiEdit/NotebookEdit; REPO_ROOT and
// KB_DIR from import.meta.url; STATE_DIR rides WORKBENCH_APP_DATA and falls
// back to the temp directory (docs/decisions/0139) ...
function main() {
let data;
try {
data = JSON.parse(readStdinSync());
} catch (error) {
return bail("could not parse the hook payload", error);
}
// Constraint 1. `stop_hook_active` is true when this turn is only still
// running because a Stop hook blocked it. Blocking again here is the infinite
// self-block: Claude finishes, the hook blocks, Claude finishes, the hook
// blocks. The harness caps that at eight consecutive blocks and then ends the
// turn anyway, so the failure is eight wasted turns rather than a hang, and
// eight wasted turns is still the whole cost of one missing line.
if (data?.stop_hook_active) return;
const transcript = data?.transcript_path;
if (typeof transcript !== "string" || transcript === "") {
return bail("no transcript_path on the payload");
}
const statePath = sidecarFor(data?.session_id);
const state = readState(statePath);
let size;
try {
size = statSync(transcript).size;
} catch (error) {
// Constraint 3. No transcript, no evidence, no block.
return bail(`could not stat ${transcript}`, error);
}
// Constraint 2. Everything before `offset` was examined at an earlier Stop.
// A shorter file than the offset is a different file under the same path, so
// the only honest offset for it is the beginning.
const offset = state.offset > size ? 0 : state.offset;
const roundStartMs = state.checkedAtMs;
let slice;
try {
slice = readRange(transcript, offset, size);
} catch (error) {
return bail(`could not read ${transcript}`, error);
}
// Write the boundary forward before deciding anything. This is what makes
// "block at most once per round" structural rather than a counter: the next
// Stop reads from here, sees the round it already judged as zero new bytes,
// and has nothing to judge.
writeState(statePath, { offset: size, checkedAtMs: Date.now() });
if (offset === size) return; // Nothing new since the last look.
if (roundStartMs === null) {
// The first Stop of a session has no earlier boundary, so there is no
// window in which a file could have been filed. It sets the boundary and
// lets the turn end rather than judging a round it cannot measure.
return;
}
const changed = changedPaths(slice);
if (changed.length === 0) return; // Constraint 4. Nothing changed, so nothing to say.
let filedAtMs;
try {
filedAtMs = newestMtimeMs(KB_DIR);
} catch (error) {
return bail(`could not read ${KB_DIR}`, error);
}
if (filedAtMs > roundStartMs) return; // Something was filed this round. Done.
process.stderr.write(`${reason(changed)}\n`);
process.exit(2); // On Stop, exit 2 is what keeps the turn going, and stderr is the instruction.
}
// ... reason() names the first changed paths and counts the rest; changedPaths()
// reads tool_use blocks from the slice; newestMtimeMs() walks knowledge-base/
// for the ground truth, skipping the per-machine agents/ scratchpads ... .claude/settings.json The whole file. Where the blocking hook is declared, and nowhere else.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/tools/hooks/enforce-capture.mjs"],
"timeout": 15
}
]
}
]
}
} One script, two events, two rules
open-questions.mjs reads knowledge-base/_questions.md and routes on the event that fired. On SessionStart it injects the blocking questions once, so a session that starts with an unanswered blocker begins with that blocker in front of the model. On UserPromptSubmit it injects a question only when the prompt uses one of that question’s trigger words, on every prompt, for the life of the session.
That split is the second of the module’s three facts: a block of questions shown once at startup is forgotten twenty turns later. A question that arrives in the turn that touches its subject is read.
Both jobs answer through the same field, hookSpecificOutput.additionalContext, a string the harness feeds to the model. The trigger table is a plain constant at the top of the file, matched as substrings against the lower-cased prompt. It holds a fragment of each question rather than the question text, so the file stays the one place the words live (record 0142). It is data whoever maintains the questions is meant to edit, and the comment above it says so.
Next, the compact guard, the line that keeps the injector from spending the context it saves. SessionStart fires five ways, and one of them is compact: when a long session compacts to make room, the harness starts a fresh session and fires the event again. An injector that does not return on that source re-injects its whole block into the window compaction just cleared, every time, forever, with no error. The guard could instead be a matcher in the config that never spawns the process on a compact source. It is read in the hook so that one handler entry covers every source and the rule sits next to the three lines that explain it (record 0144). This is the failure module 37 pointed at from the other side.
Four constraints on the hook that blocks
enforce-capture.mjs is the one hook here that refuses to end a turn, and a blocking hook has four things to get right or it turns into a loop or a nag. It runs on Stop, which fires at the end of every turn.
The first constraint is the stop_hook_active return, and it is the first thing main does. That flag is true when the turn is only still running because a Stop hook already blocked it. Block again and you have the infinite self-block: Claude finishes, the hook blocks, Claude finishes, the hook blocks. As module 09 records and this repository’s own domain facts restate, the harness caps that at eight continuations and then ends the turn anyway. The cost of forgetting this line is eight wasted turns rather than a hang.
The second constraint is the byte offset: the hook remembers how far into the transcript it read last time, in a small sidecar file, and examines only the bytes after it. The offset is advanced to the current file size before the hook decides anything, which is what makes “block at most once per round” structural rather than a counter it has to keep in step. A blocked round is marked examined even as the block happens, because the block is the hook’s one statement about that round (record 0138).
The third constraint is failing open on every error, the first of the module’s three facts applied to this hook. A Stop handler that cannot read a file it needs lets the turn end, because a session held hostage to a stray brace in a Markdown file is a hook that gets disabled by the first person it happens to (record 0137).
The fourth is saying nothing when nothing changed. Underneath all four is the ground-truth question: was anything filed this round? It is answered from the newest modification time under knowledge-base/, not from the transcript, because a Bash heredoc, a subagent’s write and a person editing a page in another window are all filing and none of them appears as a tool call the transcript records. The mtime check over-reports filing, and that error points at not blocking, which is the direction record 0140 wants every error in this hook to point.
Where each hook is allowed to live
The two hooks ship in two different places, split by what they can do. open-questions.mjs is requested for the plugin manifest, both events inline, resolved with ${CLAUDE_PLUGIN_ROOT}, because a hook that only adds a sentence to a prompt is useful to anyone who installs the plugin and harmless to everyone.
enforce-capture.mjs is declared in .claude/settings.json in this repository and nowhere else, because a hook that can refuse to end a turn should live where the person it happens to can see it and switch it off. A plugin manifest’s hooks are installed by a command and diagnosed only by somebody who already knows plugins declare hooks, which is the wrong amount of searching for “why will this session not finish” (record 0141).
Both run unsandboxed at the installer’s trust level either way. The split is about consent, not safety, which is module 11’s point that a hook is a control on behaviour and never a security boundary.
Run the hooks by hand
The hooks fire when a real session reaches the event, and no keyless run can make that happen. What a keyless machine can do is pipe a payload into a hook and read what comes out, which is the certification record 0145 settles for. From the repository root in Git Bash:
git checkout module-38
# The injector, against a fixture wiki: each prints one JSON object naming its event.
HOOK_KB_DIR=tools/hooks/fixtures/kb node tools/hooks/open-questions.mjs < tools/hooks/fixtures/sessionstart_startup.json
HOOK_KB_DIR=tools/hooks/fixtures/kb node tools/hooks/open-questions.mjs < tools/hooks/fixtures/userpromptsubmit_trigger.json
The Stop hook is proved across three rounds: the first Stop sets the boundary and exits 0; a round that changed a file outside knowledge-base/ and filed nothing exits 2 with its reason on stderr; and the immediate repeat exits 0 because the round is already examined. The full sequence, with WORKBENCH_APP_DATA and HOOK_KB_DIR set so the hook writes its sidecar and reads its wiki from fixtures, is in record 0145. The observed exits are first=0, second=2, third=0.
The ladder:
- Run it. Pipe the two injector fixtures above and read the JSON. Then pipe the compact fixture and confirm it prints nothing.
- Read one file.
tools/hooks/open-questions.mjs, and find the onereturnthat is the compact guard. - Change one line and see it. Add a row to the
TRIGGERStable for a question you care about, then pipe a prompt that uses your trigger word and watch the question come back inadditionalContext. - Build. Wire the Stop hook into
.claude/settings.json, run a round that writes a file outside the wiki and files nothing, and read the one message it produces on stderr. Then run the round again and confirm the second Stop is silent.
You hardcode hookEventName: “UserPromptSubmit” in the emit call, because both events reach the same function and one name looks tidier than passing it in. It works for the prompt hook and looks correct everywhere. On SessionStart the hook now emits a payload whose hookEventName is UserPromptSubmit. The harness reads that name, sees it does not match the event in flight, discards the whole object, and says nothing. The hook ran, exited 0, and printed a well-formed payload. The blocking questions never reach the session, and nothing on the terminal tells you why. Passing the name in from the caller, one line below the branch that knows which event fired, is what closes it (record 0143).
The second version of this trap is the Stop hook without the stop_hook_active check. It blocks a round, Claude continues, the turn tries to end, the hook fires again and blocks again, on and on. It is not an infinite hang: the harness caps continuations at eight and then lets the turn finish. What you see is eight wasted turns, a slow session, and a message repeated eight times, for a hook whose whole job was to nudge once. The check is one line at the very top of main, and it has to be first, because everything after it assumes the round is real (record 0138).
The hook concept travels well; the config and the field names do not. The cross-harness survey in research/05 records that seven of ten surveyed harnesses have a real deterministic hook mechanism, and that they converged on the core contract: a lifecycle event, a shell command, exit code 2 to block, JSON on stdout for a richer decision. What did not converge is everything around it. The event vocabulary differs in every tool, and the JSON output fields differ too, so additionalContext and hookSpecificOutput are Claude Code’s spelling of an idea other tools spell their own way. The one exception the survey names is Copilot CLI, which reads Claude Code’s hook JSON unmodified, PascalCase event names and nested matcher structure included. The mtime-and-offset logic inside these scripts is portable Node; the declarations that wire them in are not.
Check yourself
open-questions.mjsinjects the blocking questions on SessionStart. Why does it return early whensourceiscompact, and what does a reader see happen to their context if that line is removed?- The Stop hook advances its byte offset before it decides whether to block, not after. Walk what would go wrong if it advanced the offset only on the quiet path, and name the harness limit that stops it becoming a true hang.
- One hook is requested for the plugin manifest and the other is declared only in
.claude/settings.json. Which is which, and what property of the blocking hook decides that it does not travel with the plugin?