Read the script
#!/usr/bin/env node
// zenophobia — Stop hook: the finish line outside the model's judgment.
// Scans the final assistant message for loose-end species (trailing offers,
// hedged dones, follow-up bait). On hit, blocks the stop ONCE with a reason
// telling the model to close the loose end. Never blocks twice for the same
// turn — a stop gate that forces "one more thing" forever would be Zeno.
const fs = require('fs');
const path = require('path');
const os = require('os');
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, 'zenophobia', 'level');
function allow() { process.exit(0); }
function readLevel() {
try {
const raw = fs.readFileSync(flagPath, 'utf8').trim();
return ['off', 'lite', 'full', 'ultra'].includes(raw) ? raw : 'full';
} catch (e) { return 'full'; }
}
// [species label, regex] — scanned against the tail of the final message,
// code blocks stripped. Conservative on purpose: a false block is worse
// than a missed offer.
const PERFORMATIVE = [
['trailing-offer', /\bwant me to\b/i],
['trailing-offer', /\bwould you like me to\b/i],
['trailing-offer', /\bshould i (also|go ahead|proceed)\b/i],
['trailing-offer', /\bjust say the word\b/i],
['trailing-offer', /\blet me know if you(?:'d| would)? (?:like|want|need)\b/i],
['trailing-offer', /\bif you(?:'d like| like| want), i can\b/i],
['trailing-offer', /\bi can also (?:add|write|create|update|wire|set up|implement|expand)\b/i],
['trailing-offer', /\bhappy to (?:add|write|do|expand|dig|implement)\b/i],
['follow-up-bait', /\banything else[^.?!]{0,30}\?/i],
['follow-up-bait', /\bwhich of (?:these|those) would you\b/i],
];
const HEDGED = [
['hedged-done', /\bshould (?:now )?work\b/i],
['hedged-done', /\bshould be (?:fixed|working|good to go)\b/i],
['hedged-done', /\byou may want to (?:verify|double[- ]check|test)\b/i],
['hedged-done', /\bprobably (?:fixed|works)\b/i],
];
const ULTRA_EXTRA = [
['hedged-done', /\blikely (?:fixed|works)\b/i],
['next-steps', /\bnext steps?\b/i],
];
function lastAssistantText(transcriptPath) {
let text = null;
const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
let o;
try { o = JSON.parse(line); } catch (e) { continue; }
if (o.type !== 'assistant' || !o.message || !Array.isArray(o.message.content)) continue;
const parts = o.message.content.filter(c => c.type === 'text' && c.text);
if (parts.length) text = parts.map(c => c.text).join('\n');
}
return text;
}
let input = '';
process.stdin.on('data', c => { input += c; });
process.stdin.on('error', allow);
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
// Loop guard 1: already continuing because of a stop hook.
if (data.stop_hook_active) allow();
// Loop guard 2: marker file — only for harnesses whose payload lacks
// stop_hook_active entirely. When the field is present (Claude Code always
// sends it), guard 1 is authoritative — and since real payloads carry no
// prompt_id, the marker key would degrade to session_id and kill the gate
// for the rest of the session after one catch.
const useMarker = !('stop_hook_active' in data);
// encodeURIComponent: a session_id with a path separator must not turn
// the marker into an unwritable path (which would re-block forever).
const marker = path.join(os.tmpdir(),
'zenophobia-' + encodeURIComponent(String(data.session_id || 'nosession')));
const turnKey = String(data.prompt_id || data.session_id || 'unknown');
if (useMarker) {
try {
if (fs.readFileSync(marker, 'utf8') === turnKey) allow();
} catch (e) { /* no marker yet */ }
}
const level = readLevel();
if (level === 'off' || level === 'lite') allow();
// Claude Code sends the final text directly (verified in v2.1.217's Stop
// payload); transcript parse is the fallback for harnesses that don't.
let text = typeof data.last_assistant_message === 'string' && data.last_assistant_message
? data.last_assistant_message : null;
if (!text && data.transcript_path) text = lastAssistantText(data.transcript_path);
if (!text) allow();
// Loose ends live at the end of the message ("you'll know it by the way
// it arrives after the answer was already complete"). Scan the tail only,
// code blocks stripped — quoted or generated code is not the model talking.
// (?:```|$) also strips a trailing unclosed fence — a message that ends
// mid-code-block must not leak quoted text into the scan.
const tail = text.replace(/```[\s\S]*?(?:```|$)/g, '').slice(-700);
const checks = level === 'ultra'
? PERFORMATIVE.concat(HEDGED, ULTRA_EXTRA)
: PERFORMATIVE.concat(HEDGED);
for (const [species, re] of checks) {
const m = re.exec(tail);
if (!m) continue;
// Only block if the marker persists — an unpersistable marker would
// re-block this turn forever in a harness without stop_hook_active.
// Fail open.
if (useMarker) {
try { fs.writeFileSync(marker, turnKey); } catch (e) { allow(); }
}
try {
const logDir = path.join(claudeDir, 'zenophobia');
fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(path.join(logDir, 'kills.jsonl'),
JSON.stringify({ ts: new Date().toISOString(), species, match: m[0], level }) + '\n');
} catch (e) { /* stats are optional, blocking is not */ }
process.stdout.write(JSON.stringify({
decision: 'block',
reason: 'ZENOPHOBIA stop gate — loose end in your final message (' + species +
': "' + m[0] + '"). Close it: if the offered work is in scope, do it now; ' +
'if out of scope, delete the offer. Replace any hedge with a verified result ' +
'(command + exit code) or one flat "Not verified: <concrete reaso