mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-10 18:23:12 +08:00
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:
239
scripts/hooks/ecc-context-monitor.js
Normal file
239
scripts/hooks/ecc-context-monitor.js
Normal 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 };
|
||||
238
tests/hooks/ecc-context-monitor.test.js
Normal file
238
tests/hooks/ecc-context-monitor.test.js
Normal 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);
|
||||
Reference in New Issue
Block a user