Skip to content
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.

From plugin
zenophobia
41 command3 hooks
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