#!/usr/bin/env node /** * ECC Context Monitor — PostToolUse hook * * Reads bridge file from ecc-metrics-bridge.js and injects agent-facing * warnings when thresholds are crossed: context exhaustion, high cost, * scope creep, or tool loops. */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const { sanitizeSessionId, readBridge } = require('../lib/session-bridge'); const CONTEXT_WARNING_PCT = 35; const CONTEXT_CRITICAL_PCT = 25; const COST_NOTICE_USD = 5; const COST_WARNING_USD = 10; const COST_CRITICAL_USD = 50; const FILES_WARNING_COUNT = 20; const LOOP_THRESHOLD = 3; const STALE_SECONDS = 60; const DEBOUNCE_CALLS = 5; /** * Get debounce state file path. * @param {string} sessionId * @returns {string} */ function getWarnPath(sessionId) { return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`); } /** * Read debounce state. * @param {string} sessionId * @returns {object} */ function readWarnState(sessionId) { try { return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8')); } catch { return { callsSinceWarn: 0, lastSeverity: null }; } } /** * Write debounce state. * @param {string} sessionId * @param {object} state */ function writeWarnState(sessionId, state) { const target = getWarnPath(sessionId); const tmp = `${target}.tmp`; fs.writeFileSync(tmp, JSON.stringify(state), 'utf8'); fs.renameSync(tmp, target); } /** * Detect tool loops from recent_tools ring buffer. * @param {Array} recentTools * @returns {{detected: boolean, tool: string, count: number}} */ function detectLoop(recentTools) { if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) { return { detected: false, tool: '', count: 0 }; } const counts = {}; for (const entry of recentTools) { const key = `${entry.tool}:${entry.hash}`; counts[key] = (counts[key] || 0) + 1; } for (const [key, count] of Object.entries(counts)) { if (count >= LOOP_THRESHOLD) { return { detected: true, tool: key.split(':')[0], count }; } } return { detected: false, tool: '', count: 0 }; } /** * Evaluate all warning conditions against bridge data. * Returns array of {severity, type, message} sorted by severity desc. */ function evaluateConditions(bridge) { const warnings = []; const remaining = bridge.context_remaining_pct; // Context warnings (skip if no context data) if (remaining !== null && remaining !== undefined) { if (remaining <= CONTEXT_CRITICAL_PCT) { warnings.push({ severity: 3, type: 'context', message: `CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` + 'Inform the user that context is low and ask how they want to proceed. ' + 'Do NOT autonomously save state or write handoff files unless the user asks.' }); } else if (remaining <= CONTEXT_WARNING_PCT) { warnings.push({ severity: 2, type: 'context', message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.' }); } } // Cost warnings const cost = bridge.total_cost_usd || 0; if (cost > COST_CRITICAL_USD) { warnings.push({ severity: 3, type: 'cost', message: `COST CRITICAL: Session cost is $${cost.toFixed(2)}. ` + 'Stop and inform the user about high cost before continuing.' }); } else if (cost > COST_WARNING_USD) { warnings.push({ severity: 2, type: 'cost', message: `COST WARNING: Session cost is $${cost.toFixed(2)}. ` + 'Review whether the current approach justifies the expense.' }); } else if (cost > COST_NOTICE_USD) { warnings.push({ severity: 1, type: 'cost', message: `COST NOTICE: Session cost is $${cost.toFixed(2)}. ` + 'Consider whether the current approach is efficient.' }); } // File scope warning const fileCount = bridge.files_modified_count || 0; if (fileCount > FILES_WARNING_COUNT) { warnings.push({ severity: 2, type: 'scope', message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.' }); } // Loop detection const loop = detectLoop(bridge.recent_tools); if (loop.detected) { warnings.push({ severity: 2, type: 'loop', message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.' }); } return warnings.sort((a, b) => b.severity - a.severity); } /** * Map numeric severity to label. */ function severityLabel(n) { if (n >= 3) return 'critical'; if (n >= 2) return 'warning'; return 'notice'; } /** * @param {string} rawInput - Raw JSON string from stdin * @returns {string} JSON output with additionalContext or pass-through */ function run(rawInput) { try { const input = rawInput.trim() ? JSON.parse(rawInput) : {}; const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID); if (!sessionId) return rawInput; const bridge = readBridge(sessionId); if (!bridge) return rawInput; // Stale check for context warnings const now = Math.floor(Date.now() / 1000); const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0; const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS; // If bridge is stale, null out context data (still check cost/scope/loop) const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge; const warnings = evaluateConditions(evalBridge); if (warnings.length === 0) return rawInput; // Debounce logic const warnState = readWarnState(sessionId); warnState.callsSinceWarn = (warnState.callsSinceWarn || 0) + 1; const topSeverity = severityLabel(warnings[0].severity); const severityEscalated = topSeverity === 'critical' && warnState.lastSeverity !== 'critical'; const isFirst = !warnState.lastSeverity; if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) { writeWarnState(sessionId, warnState); return rawInput; } // Reset debounce, emit warning warnState.callsSinceWarn = 0; warnState.lastSeverity = topSeverity; writeWarnState(sessionId, warnState); // Combine top 2 warnings const message = warnings .slice(0, 2) .map(w => w.message) .join('\n'); const output = { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: message } }; return JSON.stringify(output); } catch { // Never block tool execution return rawInput; } } if (require.main === module) { let data = ''; const MAX_STDIN = 1024 * 1024; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length); }); process.stdin.on('end', () => { process.stdout.write(run(data)); process.exit(0); }); } module.exports = { run, evaluateConditions, detectLoop, severityLabel };