OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / tool-watcher.js

182 lines · javascript ref:
#!/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))