#!/usr/bin/env node /** * PostToolUse hook — blocks Claude from silently retrying when tools * flake. Reads the PostToolUse payload on stdin, detects failure * signals (is_error, "tool result missing", "internal error"), appends * to a rolling log, and EXITS 2 (blocking) when a threshold is reached: * - 3 failures of the same tool in 2 min * - 2 "internal error" matches in 5 min * - 2 "tool result missing" matches in 5 min * * Exit 2 forces Claude to surface the issue to the user instead of * pressing on. */ const fs = require('fs'); const path = require('path'); const { detectFailure, appendFailure, readRecent, evaluate, } = require(path.join(__dirname, 'lib', 'failure-detector.js')); const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const TOOL_LOG_PATH = path.join(projectDir, '_byan-output', 'tool-log.jsonl'); function appendToolLog(entry) { try { fs.mkdirSync(path.dirname(TOOL_LOG_PATH), { recursive: true }); fs.appendFileSync(TOOL_LOG_PATH, JSON.stringify(entry) + '\n'); } catch { // visibility log must never block the hook } } function readStdin() { return new Promise((resolve) => { if (process.stdin.isTTY) return resolve(''); let data = ''; process.stdin.on('data', (c) => (data += c)); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', () => resolve(data)); }); } (async () => { const raw = await readStdin(); let payload = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; } const toolName = payload.tool_name || payload.toolName || 'unknown'; const hit = detectFailure(payload); const respStr = JSON.stringify( payload.tool_response ?? payload.toolResponse ?? payload.response ?? {} ); const estOutputTokens = Math.ceil(respStr.length / 4); appendToolLog({ timestamp: new Date().toISOString(), phase: 'post', tool: toolName, ok: !hit, failure_kind: hit ? hit.kind : null, est_output_tokens: estOutputTokens, }); if (!hit) { process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: '' }, }) ); process.exit(0); } const event = { timestamp: new Date(), tool_name: toolName, kind: hit.kind, detail: hit.detail }; appendFailure(event); const entries = readRecent(); const verdict = evaluate({ entries, toolName }); if (verdict.blocked) { const msg = [ `BLOCKED by tool-failure-guard: ${verdict.reason} (${verdict.count} events).`, 'Surface this to the user before any further tool call. Do not retry silently.', 'Recent events:', ...verdict.recent.map( (e) => ` - ${e.timestamp} ${e.tool_name}: ${(e.detail || '').slice(0, 120)}` ), ].join('\n'); process.stderr.write(msg + '\n'); process.stdout.write( JSON.stringify({ decision: 'block', reason: verdict.reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: msg }, }) ); process.exit(2); } process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: `Tool failure recorded (${hit.kind}). Continuing, but be explicit with the user if a retry fails.`, }, }) ); process.exit(0); })();