Development
Hook
Hooks
What zenophobia runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add NegativeZone/zenophobia > /plugin install zenophobia@zenophobia
Ships with zenophobia. Installing the plugin gets these hooks.
Where it lives
- hooks/zeno-activate.jsGitHub
Read the script
#!/usr/bin/env node // zenophobia — SessionStart hook: emit ruleset at active level. 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'); const LEVELS = ['lite', 'full', 'ultra']; let level = 'full'; try { const raw = fs.readFileSync(flagPath, 'utf8').trim(); if (raw === 'off') process.exit(0); if (LEVELS.includes(raw)) level = raw; } catch (e) { /* no flag file — default full */ } let rules; try { rules = fs.readFileSync(path.join(__dirname, 'zeno-rules.md'), 'utf8'); } catch (e) { // Minimum viable ruleset if zeno-rules.md is missing. rules = 'Close the task. No trailing offers ("Want me to also...?"), no hedged dones ' + '("should work, you may want to verify" — verify or state "Not verified: <reason>"), ' + 'no seeded TODOs, no follow-up bait. 100% of the task or explicit "Skipped Y: <reason>". ' + 'Final message: what changed, evidence, then stop.'; } process.stdout.write('ZENOPHOBIA ACTIVE — level: ' + level + '\n\n' + rules); - hooks/zeno-stop.jsGitHub
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 - hooks/zeno-tracker.jsGitHub
Read the script
#!/usr/bin/env node // zenophobia — UserPromptSubmit hook: track /zenophobia level switches, // re-inject a one-line reminder every turn while active. 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'); const LEVELS = ['lite', 'full', 'ultra']; function writeLevel(v) { try { fs.mkdirSync(path.dirname(flagPath), { recursive: true }); fs.writeFileSync(flagPath, v); } catch (e) { // Never fail the prompt, but a silent no-op on "/zenophobia off" is worse // than a stderr line. process.stderr.write('zenophobia: could not write level: ' + e.message + '\n'); } } function readLevel() { try { const raw = fs.readFileSync(flagPath, 'utf8').trim(); return raw === 'off' || LEVELS.includes(raw) ? raw : 'full'; } catch (e) { return 'full'; } } let input = ''; process.stdin.on('data', c => { input += c; }); process.stdin.on('error', () => process.exit(0)); // hooks must always exit 0 process.stdin.on('end', () => { try { const data = JSON.parse(input); const prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' '); // Deactivation first, so "turn zenophobia off" never re-arms it. if (/\b(stop|disable|deactivate|quit|exit)\s+(the\s+)?zenophobia\b/.test(prompt) || /\bzenophobia\s+(off|stop|disabled?)\b/.test(prompt) || /^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+)?normal\s+mode\b/.test(prompt)) { writeLevel('off'); return; } // /zenophobia [level] — plain and marketplace-namespaced forms. const m = /^\/zenophobia(?::zenophobia)?(?=\s|$)(?:\s+(\S+))?/.exec(prompt); if (m) { const arg = m[1] || 'full'; if (LEVELS.includes(arg) || arg === 'off') { writeLevel(arg); } // Unknown arg: flag untouched. } const level = readLevel(); if (level !== 'off') { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: 'ZENOPHOBIA ACTIVE (' + level + '). Close the task. ' + 'No trailing offers, no follow-up bait, no hedged dones, no seeded TODOs. ' + 'Done = evidence or flat "Not verified: <reason>". Then stop.' } })); } } catch (e) { /* silent fail — never block the prompt */ } });
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withzenophobia
Fear of Zeno. Makes your coding agent finish. Ask an agent for a one-line fix and you get the fix plus a question: want me to also add tests? Say yes and the tests arrive with an offer to wire up CI. The task goes 90% done, then 95, then 97.5.
Get the whole plugin
Stats
4
Stars
0
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
1mo ago
Last commit
1mo ago
Created
Repo: NegativeZone/zenophobia

