mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-13 18:00:35 +08:00
Salvages the useful statusline/context monitor work from stale PR #1504 while preserving the current continuous-learning hook runner wiring. Adds the metrics bridge, context monitor, statusline script, shared cost/session bridge utilities, and tests. Fixes the reviewed false loop-detection hash collision for non-file tools, avoids default-session cost inflation, sanitizes statusline task lookup, and records hook payload session IDs in cost-tracker.
243 lines
7.1 KiB
JavaScript
243 lines
7.1 KiB
JavaScript
#!/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 };
|