diff --git a/scripts/hooks/ecc-context-monitor.js b/scripts/hooks/ecc-context-monitor.js new file mode 100644 index 00000000..c3b28800 --- /dev/null +++ b/scripts/hooks/ecc-context-monitor.js @@ -0,0 +1,239 @@ +#!/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) { + fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8'); +} + +/** + * 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) { + 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 }; diff --git a/tests/hooks/ecc-context-monitor.test.js b/tests/hooks/ecc-context-monitor.test.js new file mode 100644 index 00000000..bbecc4ed --- /dev/null +++ b/tests/hooks/ecc-context-monitor.test.js @@ -0,0 +1,238 @@ +/** + * Tests for scripts/hooks/ecc-context-monitor.js + * + * Run with: node tests/hooks/ecc-context-monitor.test.js + */ + +const assert = require('assert'); + +const { run, evaluateConditions, detectLoop, severityLabel } = require('../../scripts/hooks/ecc-context-monitor'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing ecc-context-monitor.js ===\n'); + + let passed = 0; + let failed = 0; + + // evaluateConditions — context warnings + console.log('evaluateConditions (context):'); + + if ( + test('remaining 20% triggers CRITICAL context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 20 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.ok(ctx, 'Expected a context warning'); + assert.strictEqual(ctx.severity, 3); + assert.ok(ctx.message.includes('CRITICAL'), 'Message should contain CRITICAL'); + }) + ) + passed++; + else failed++; + + if ( + test('remaining 30% triggers WARNING context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 30 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.ok(ctx, 'Expected a context warning'); + assert.strictEqual(ctx.severity, 2); + assert.ok(ctx.message.includes('WARNING'), 'Message should contain WARNING'); + }) + ) + passed++; + else failed++; + + if ( + test('remaining 50% triggers no context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 50 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.strictEqual(ctx, undefined); + }) + ) + passed++; + else failed++; + + // evaluateConditions — cost warnings + console.log('\nevaluateConditions (cost):'); + + if ( + test('cost $55 triggers CRITICAL cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 55 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 3); + assert.ok(cost.message.includes('CRITICAL'), 'Message should contain CRITICAL'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $12 triggers WARNING cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 12 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 2); + assert.ok(cost.message.includes('WARNING'), 'Message should contain WARNING'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $6 triggers NOTICE cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 6 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 1); + assert.ok(cost.message.includes('NOTICE'), 'Message should contain NOTICE'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $2 triggers no cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 2 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.strictEqual(cost, undefined); + }) + ) + passed++; + else failed++; + + // evaluateConditions — scope warnings + console.log('\nevaluateConditions (scope):'); + + if ( + test('25 files triggers scope WARNING', () => { + const warnings = evaluateConditions({ files_modified_count: 25 }); + const scope = warnings.find(w => w.type === 'scope'); + assert.ok(scope, 'Expected a scope warning'); + assert.strictEqual(scope.severity, 2); + assert.ok(scope.message.includes('SCOPE'), 'Message should contain SCOPE'); + }) + ) + passed++; + else failed++; + + if ( + test('10 files triggers no scope warning', () => { + const warnings = evaluateConditions({ files_modified_count: 10 }); + const scope = warnings.find(w => w.type === 'scope'); + assert.strictEqual(scope, undefined); + }) + ) + passed++; + else failed++; + + // detectLoop tests + console.log('\ndetectLoop:'); + + if ( + test('3 identical entries returns detected true', () => { + const entries = [ + { tool: 'Bash', hash: 'aabbccdd' }, + { tool: 'Bash', hash: 'aabbccdd' }, + { tool: 'Bash', hash: 'aabbccdd' } + ]; + const result = detectLoop(entries); + assert.strictEqual(result.detected, true); + assert.strictEqual(result.tool, 'Bash'); + assert.ok(result.count >= 3); + }) + ) + passed++; + else failed++; + + if ( + test('all different entries returns detected false', () => { + const entries = [ + { tool: 'Bash', hash: '11111111' }, + { tool: 'Edit', hash: '22222222' }, + { tool: 'Write', hash: '33333333' } + ]; + const result = detectLoop(entries); + assert.strictEqual(result.detected, false); + }) + ) + passed++; + else failed++; + + if ( + test('empty array returns detected false', () => { + const result = detectLoop([]); + assert.strictEqual(result.detected, false); + }) + ) + passed++; + else failed++; + + // severityLabel tests + console.log('\nseverityLabel:'); + + if ( + test('severity 3 returns critical', () => { + assert.strictEqual(severityLabel(3), 'critical'); + }) + ) + passed++; + else failed++; + + if ( + test('severity 2 returns warning', () => { + assert.strictEqual(severityLabel(2), 'warning'); + }) + ) + passed++; + else failed++; + + if ( + test('severity 1 returns notice', () => { + assert.strictEqual(severityLabel(1), 'notice'); + }) + ) + passed++; + else failed++; + + // run tests + console.log('\nrun:'); + + if ( + test('empty input returns input unchanged', () => { + const result = run(''); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + if ( + test('input without session_id returns input unchanged', () => { + const input = JSON.stringify({ tool_name: 'Bash' }); + const result = run(input); + assert.strictEqual(result, input); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0);