117 lines
3.3 KiB
JavaScript
Executable file
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);
|
|
})();
|