#!/usr/bin/env node
/**
* tool-watcher.js — Proactive PostToolUse intervention hook
*
* Watches for behavioral patterns DURING execution (not after edit):
* - Same tool + similar args N times (redundant retry)
* - Edit/Write on a file that was never Read
* - Rapid Bash attempts (thrashing)
* - Repetitive Grep patterns (not finding what we need)
* - Too many tool calls with no edits (fumbling)
*
* Writes advice to stderr (async, non-blocking). Claude sees the advice
* via hook output, user sees it in the session log.
*
* State kept in /tmp/claude-tool-watcher-state.json so we can track tool
* usage across turns.
*/
const fs = require('fs')
const STATE_FILE = '/tmp/claude-tool-watcher-state.json'
const SESSION_FILE = '/tmp/claude-atomspace-session'
const MAX_STATE_TOOLS = 30 // keep last 30 tool calls
function readState () {
try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) }
catch { return { tools: [], readFiles: new Set(), session: null } }
}
function writeState (state) {
const serializable = {
tools: state.tools,
readFiles: [...(state.readFiles || [])], // convert Set to array
session: state.session,
lastTurn: state.lastTurn
}
try { fs.writeFileSync(STATE_FILE, JSON.stringify(serializable)) } catch {}
}
function loadSessionId () {
try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null }
}
/**
* Classify a tool call into a category and extract key args for similarity check.
*/
function summarize (toolName, toolInput) {
const input = toolInput || {}
switch (toolName) {
case 'Read':
return { kind: 'read', target: input.file_path || '', full: `${toolName}:${input.file_path}` }
case 'Edit':
return { kind: 'edit', target: input.file_path || '', full: `${toolName}:${input.file_path}:${(input.old_string || '').slice(0, 40)}` }
case 'Write':
return { kind: 'write', target: input.file_path || '', full: `${toolName}:${input.file_path}` }
case 'Bash':
return { kind: 'bash', target: (input.command || '').slice(0, 80), full: `Bash:${(input.command || '').slice(0, 80)}` }
case 'Grep':
return { kind: 'grep', target: input.pattern || '', full: `Grep:${input.pattern}:${input.path || ''}` }
case 'Glob':
return { kind: 'glob', target: input.pattern || '', full: `Glob:${input.pattern}` }
default:
return { kind: 'other', target: toolName, full: toolName }
}
}
/**
* Detect patterns from the recent tool history.
* Returns array of { severity, message } advice items.
*/
function detectPatterns (state, currentTool) {
const advice = []
const recent = state.tools.slice(-10)
// Pattern 1: Same tool+args 3+ times in last 5 calls
const last5 = state.tools.slice(-5)
const sameAsLast = last5.filter(t => t.full === currentTool.full).length
if (sameAsLast >= 3) {
advice.push({
severity: 'high',
message: `Same tool call 3+ times (${currentTool.kind}: ${currentTool.target.slice(0, 60)}). Try a different approach.`
})
}
// Pattern 2: Repeated Grep on same term with nothing found
if (currentTool.kind === 'grep') {
const recentGreps = recent.filter(t => t.kind === 'grep')
if (recentGreps.length >= 4) {
advice.push({
severity: 'med',
message: `4+ Greps recently. Consider reading the file directly instead of pattern-matching.`
})
}
}
// Pattern 3: Edit/Write without prior Read on that file
if (currentTool.kind === 'edit' || currentTool.kind === 'write') {
const hasRead = (state.readFiles || []).includes(currentTool.target)
if (!hasRead) {
advice.push({
severity: 'high',
message: `Editing ${currentTool.target.split('/').pop()} without having Read it first. Read the full file before editing.`
})
}
}
// Pattern 4: 5+ Bash calls with no Edit/Write in between (thrashing)
const recentBashCount = recent.filter(t => t.kind === 'bash').length
const recentEditCount = recent.filter(t => t.kind === 'edit' || t.kind === 'write').length
if (recentBashCount >= 5 && recentEditCount === 0) {
advice.push({
severity: 'med',
message: `${recentBashCount} Bash calls recently with no edits. Debugging loop? Consider stopping to think.`
})
}
// Pattern 5: 10+ tool calls since last correction without an edit
// (just churning without producing output)
const callsSinceEdit = state.tools.length - state.tools.findLastIndex(t => t.kind === 'edit' || t.kind === 'write')
if (callsSinceEdit > 12 && state.tools.length > 15) {
advice.push({
severity: 'low',
message: `${callsSinceEdit} tool calls since last edit. Narrow the scope or make a concrete change.`
})
}
return advice
}
async function main () {
// Read hook input
let input = ''
for await (const chunk of process.stdin) input += chunk
let data
try { data = JSON.parse(input) } catch { process.exit(0) }
const toolName = data.tool_name || data.tool
if (!toolName) process.exit(0)
// Reset state if new session
const sessionId = loadSessionId()
let state = readState()
if (state.session !== sessionId) {
state = { tools: [], readFiles: [], session: sessionId, lastTurn: 0 }
}
// Convert readFiles back to array (we serialized as array)
if (!Array.isArray(state.readFiles)) state.readFiles = []
const toolSummary = summarize(toolName, data.tool_input)
// Update state
state.tools.push(toolSummary)
if (state.tools.length > MAX_STATE_TOOLS) {
state.tools = state.tools.slice(-MAX_STATE_TOOLS)
}
if (toolSummary.kind === 'read' && toolSummary.target) {
if (!state.readFiles.includes(toolSummary.target)) state.readFiles.push(toolSummary.target)
}
writeState(state)
// Detect patterns
const advice = detectPatterns(state, toolSummary)
if (advice.length === 0) process.exit(0)
// Cap output to avoid noise — top severity only
const sevOrder = { high: 0, med: 1, low: 2 }
advice.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity])
const top = advice[0]
// Write to stderr (non-blocking guidance)
// Claude Code hooks surface stderr as a system message
const marker = top.severity === 'high' ? '‼' : top.severity === 'med' ? '⚠' : '·'
process.stderr.write(`[tool-watcher] ${marker} ${top.message}\n`)
process.exit(0)
}
main().catch(() => process.exit(0))