site-mariage/.claude/hooks/tool-failure-guard.js
Corentin Joguet bff653acd6 first commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:30:37 +02:00

117 lines
3.3 KiB
JavaScript
Executable file

#!/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);
})();