feat: add ecc-context-monitor with multi-dimensional warnings

PostToolUse hook injecting agent-facing warnings for:
- Context exhaustion (35% WARNING, 25% CRITICAL)
- Session cost ($5 NOTICE, $10 WARNING, $50 CRITICAL)
- Scope creep (>20 files modified)
- Tool loops (same tool+params 3x in last 5 calls)

Includes debounce (5 calls between warnings) with severity escalation bypass.
This commit is contained in:
ulinzeng
2026-04-20 15:15:35 +08:00
parent aec611a98b
commit cf79534c2c
2 changed files with 477 additions and 0 deletions

View File

@@ -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 };

View File

@@ -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);