From f579dad768d004f02c2d00775042db1432ec616d Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:07 +0800 Subject: [PATCH 1/6] feat: add cost-estimate and session-bridge shared libs Extract estimateCost() into scripts/lib/cost-estimate.js for reuse across cost-tracker and ecc-metrics-bridge hooks. Add scripts/lib/session-bridge.js with atomic bridge file I/O, session ID sanitization, and path traversal prevention. --- scripts/lib/cost-estimate.js | 32 ++++++ scripts/lib/session-bridge.js | 81 ++++++++++++++ tests/lib/cost-estimate.test.js | 114 ++++++++++++++++++++ tests/lib/session-bridge.test.js | 174 +++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 scripts/lib/cost-estimate.js create mode 100644 scripts/lib/session-bridge.js create mode 100644 tests/lib/cost-estimate.test.js create mode 100644 tests/lib/session-bridge.test.js diff --git a/scripts/lib/cost-estimate.js b/scripts/lib/cost-estimate.js new file mode 100644 index 0000000000..a1651a8c95 --- /dev/null +++ b/scripts/lib/cost-estimate.js @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Shared cost estimation for ECC hooks. + * + * Approximate per-1M-token blended rates (conservative defaults). + */ + +const RATE_TABLE = { + haiku: { in: 0.8, out: 4.0 }, + sonnet: { in: 3.0, out: 15.0 }, + opus: { in: 15.0, out: 75.0 } +}; + +/** + * Estimate USD cost from token counts. + * @param {string} model - Model name (may contain "haiku", "sonnet", or "opus") + * @param {number} inputTokens + * @param {number} outputTokens + * @returns {number} Estimated cost in USD (rounded to 6 decimal places) + */ +function estimateCost(model, inputTokens, outputTokens) { + const normalized = String(model || '').toLowerCase(); + let rates = RATE_TABLE.sonnet; + if (normalized.includes('haiku')) rates = RATE_TABLE.haiku; + if (normalized.includes('opus')) rates = RATE_TABLE.opus; + + const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out; + return Math.round(cost * 1e6) / 1e6; +} + +module.exports = { estimateCost, RATE_TABLE }; diff --git a/scripts/lib/session-bridge.js b/scripts/lib/session-bridge.js new file mode 100644 index 0000000000..aceae9cb32 --- /dev/null +++ b/scripts/lib/session-bridge.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Shared session bridge utilities for ECC hooks. + * + * The bridge file is a small JSON aggregate in /tmp that allows + * statusline, metrics-bridge, and context-monitor to share state + * without scanning large JSONL logs on every invocation. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const MAX_SESSION_ID_LENGTH = 64; + +/** + * Sanitize a session ID for safe use in file paths. + * Rejects path traversal, strips unsafe chars, limits length. + * @param {string} raw + * @returns {string|null} Safe session ID or null if invalid + */ +function sanitizeSessionId(raw) { + if (!raw || typeof raw !== 'string') return null; + if (/[/\\]|\.\./.test(raw)) return null; + const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH); + return safe || null; +} + +/** + * Get the bridge file path for a session. + * @param {string} sessionId - Already-sanitized session ID + * @returns {string} + */ +function getBridgePath(sessionId) { + return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`); +} + +/** + * Read bridge data. Returns null on any error. + * @param {string} sessionId - Already-sanitized session ID + * @returns {object|null} + */ +function readBridge(sessionId) { + try { + const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Write bridge data atomically (write .tmp then rename). + * @param {string} sessionId - Already-sanitized session ID + * @param {object} data + */ +function writeBridgeAtomic(sessionId, data) { + const target = getBridgePath(sessionId); + const tmp = `${target}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data), 'utf8'); + fs.renameSync(tmp, target); +} + +/** + * Resolve session ID from environment variables. + * @returns {string|null} Sanitized session ID or null + */ +function resolveSessionId() { + const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || ''; + return sanitizeSessionId(raw); +} + +module.exports = { + sanitizeSessionId, + getBridgePath, + readBridge, + writeBridgeAtomic, + resolveSessionId, + MAX_SESSION_ID_LENGTH +}; diff --git a/tests/lib/cost-estimate.test.js b/tests/lib/cost-estimate.test.js new file mode 100644 index 0000000000..bcb5906bc9 --- /dev/null +++ b/tests/lib/cost-estimate.test.js @@ -0,0 +1,114 @@ +/** + * Tests for scripts/lib/cost-estimate.js + * + * Run with: node tests/lib/cost-estimate.test.js + */ + +const assert = require('assert'); + +const { estimateCost, RATE_TABLE } = require('../../scripts/lib/cost-estimate'); + +// 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 cost-estimate.js ===\n'); + + let passed = 0; + let failed = 0; + + // RATE_TABLE structure + console.log('RATE_TABLE:'); + + if ( + test('RATE_TABLE has haiku, sonnet, opus keys', () => { + assert.ok(RATE_TABLE.haiku, 'Missing haiku'); + assert.ok(RATE_TABLE.sonnet, 'Missing sonnet'); + assert.ok(RATE_TABLE.opus, 'Missing opus'); + assert.strictEqual(typeof RATE_TABLE.haiku.in, 'number'); + assert.strictEqual(typeof RATE_TABLE.haiku.out, 'number'); + assert.strictEqual(typeof RATE_TABLE.sonnet.in, 'number'); + assert.strictEqual(typeof RATE_TABLE.sonnet.out, 'number'); + assert.strictEqual(typeof RATE_TABLE.opus.in, 'number'); + assert.strictEqual(typeof RATE_TABLE.opus.out, 'number'); + }) + ) + passed++; + else failed++; + + // estimateCost tests + console.log('\nestimateCost:'); + + if ( + test('opus 1M/1M tokens returns 90', () => { + const cost = estimateCost('opus', 1_000_000, 1_000_000); + assert.strictEqual(cost, 90); + }) + ) + passed++; + else failed++; + + if ( + test('sonnet 1M/1M tokens returns 18', () => { + const cost = estimateCost('sonnet', 1_000_000, 1_000_000); + assert.strictEqual(cost, 18); + }) + ) + passed++; + else failed++; + + if ( + test('haiku 1M/1M tokens returns 4.8', () => { + const cost = estimateCost('haiku', 1_000_000, 1_000_000); + assert.strictEqual(cost, 4.8); + }) + ) + passed++; + else failed++; + + if ( + test('null model with 0 tokens returns 0', () => { + const cost = estimateCost(null, 0, 0); + assert.strictEqual(cost, 0); + }) + ) + passed++; + else failed++; + + if ( + test('full model name claude-opus-4-6 uses opus rates', () => { + const cost = estimateCost('claude-opus-4-6', 500, 200); + // (500 / 1_000_000) * 15 + (200 / 1_000_000) * 75 = 0.0075 + 0.015 = 0.0225 + const expected = Math.round(0.0225 * 1e6) / 1e6; + assert.strictEqual(cost, expected); + }) + ) + passed++; + else failed++; + + if ( + test('unknown model falls back to sonnet rates', () => { + const cost = estimateCost('unknown-model', 1_000_000, 1_000_000); + assert.strictEqual(cost, 18); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/session-bridge.test.js b/tests/lib/session-bridge.test.js new file mode 100644 index 0000000000..60841c9b91 --- /dev/null +++ b/tests/lib/session-bridge.test.js @@ -0,0 +1,174 @@ +/** + * Tests for scripts/lib/session-bridge.js + * + * Run with: node tests/lib/session-bridge.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); + +const { sanitizeSessionId, getBridgePath, readBridge, writeBridgeAtomic, resolveSessionId, MAX_SESSION_ID_LENGTH } = require('../../scripts/lib/session-bridge'); + +// 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 session-bridge.js ===\n'); + + let passed = 0; + let failed = 0; + + // sanitizeSessionId tests + console.log('sanitizeSessionId:'); + + if ( + test('valid ID passes through', () => { + assert.strictEqual(sanitizeSessionId('abc-123'), 'abc-123'); + }) + ) + passed++; + else failed++; + + if ( + test('path traversal returns null', () => { + assert.strictEqual(sanitizeSessionId('../etc/passwd'), null); + }) + ) + passed++; + else failed++; + + if ( + test('forward slash returns null', () => { + assert.strictEqual(sanitizeSessionId('/tmp/evil'), null); + }) + ) + passed++; + else failed++; + + if ( + test('backslash returns null', () => { + assert.strictEqual(sanitizeSessionId('a\\b'), null); + }) + ) + passed++; + else failed++; + + if ( + test('null input returns null', () => { + assert.strictEqual(sanitizeSessionId(null), null); + }) + ) + passed++; + else failed++; + + if ( + test('empty string returns null', () => { + assert.strictEqual(sanitizeSessionId(''), null); + }) + ) + passed++; + else failed++; + + if ( + test('long string is truncated to MAX_SESSION_ID_LENGTH', () => { + const longId = 'a'.repeat(100); + const result = sanitizeSessionId(longId); + assert.ok(result, 'Should not return null for valid chars'); + assert.strictEqual(result.length, MAX_SESSION_ID_LENGTH); + }) + ) + passed++; + else failed++; + + // getBridgePath tests + console.log('\ngetBridgePath:'); + + if ( + test('returns path containing ecc-metrics-', () => { + const p = getBridgePath('test-session'); + assert.ok(p.includes('ecc-metrics-'), `Expected ecc-metrics- in path, got: ${p}`); + }) + ) + passed++; + else failed++; + + // writeBridgeAtomic + readBridge roundtrip + console.log('\nwriteBridgeAtomic / readBridge:'); + + if ( + test('roundtrip write then read returns same data', () => { + const testId = `test-bridge-${Date.now()}`; + const data = { session_id: testId, tool_count: 42 }; + try { + writeBridgeAtomic(testId, data); + const result = readBridge(testId); + assert.deepStrictEqual(result, data); + } finally { + // Clean up + try { + fs.unlinkSync(getBridgePath(testId)); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + test('readBridge with nonexistent session returns null', () => { + const result = readBridge('nonexistent-session-id-999'); + assert.strictEqual(result, null); + }) + ) + passed++; + else failed++; + + // resolveSessionId tests + console.log('\nresolveSessionId:'); + + if ( + test('resolveSessionId uses ECC_SESSION_ID env var', () => { + const original = process.env.ECC_SESSION_ID; + try { + process.env.ECC_SESSION_ID = 'env-session-42'; + const result = resolveSessionId(); + assert.strictEqual(result, 'env-session-42'); + } finally { + if (original === undefined) { + delete process.env.ECC_SESSION_ID; + } else { + process.env.ECC_SESSION_ID = original; + } + } + }) + ) + passed++; + else failed++; + + if ( + test('MAX_SESSION_ID_LENGTH is 64', () => { + assert.strictEqual(MAX_SESSION_ID_LENGTH, 64); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0); From b5294fc89a2cac435f75c1891aba8e05e7c30850 Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:13 +0800 Subject: [PATCH 2/6] refactor: extract estimateCost from cost-tracker to shared lib Replace inline estimateCost() in cost-tracker.js with import from scripts/lib/cost-estimate.js. No behavior change. --- scripts/hooks/cost-tracker.js | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 817ff77a8e..9a1587b79e 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -8,11 +8,8 @@ 'use strict'; const path = require('path'); -const { - ensureDir, - appendFile, - getClaudeDir, -} = require('../lib/utils'); +const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); +const { estimateCost } = require('../lib/cost-estimate'); const MAX_STDIN = 1024 * 1024; let raw = ''; @@ -22,23 +19,6 @@ function toNumber(value) { return Number.isFinite(n) ? n : 0; } -function estimateCost(model, inputTokens, outputTokens) { - // Approximate per-1M-token blended rates. Conservative defaults. - const table = { - 'haiku': { in: 0.8, out: 4.0 }, - 'sonnet': { in: 3.0, out: 15.0 }, - 'opus': { in: 15.0, out: 75.0 }, - }; - - const normalized = String(model || '').toLowerCase(); - let rates = table.sonnet; - if (normalized.includes('haiku')) rates = table.haiku; - if (normalized.includes('opus')) rates = table.opus; - - const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out; - return Math.round(cost * 1e6) / 1e6; -} - process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { @@ -66,7 +46,7 @@ process.stdin.on('end', () => { model, input_tokens: inputTokens, output_tokens: outputTokens, - estimated_cost_usd: estimateCost(model, inputTokens, outputTokens), + estimated_cost_usd: estimateCost(model, inputTokens, outputTokens) }; appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); From 0f0efd7d7c35a0d1467123338b16599325a9d421 Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:20 +0800 Subject: [PATCH 3/6] feat: add ecc-metrics-bridge PostToolUse hook Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json with cost, tool count, files modified, and recent tool ring buffer for loop detection. Bridge file is read by ecc-statusline and ecc-context-monitor. --- scripts/hooks/ecc-metrics-bridge.js | 185 +++++++++++++++++++++++++ tests/hooks/ecc-metrics-bridge.test.js | 166 ++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 scripts/hooks/ecc-metrics-bridge.js create mode 100644 tests/hooks/ecc-metrics-bridge.test.js diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js new file mode 100644 index 0000000000..3ba3f81e87 --- /dev/null +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * ECC Metrics Bridge — PostToolUse hook + * + * Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json. + * This bridge file is read by ecc-statusline.js and ecc-context-monitor.js, + * avoiding the need to scan large JSONL logs on every invocation. + */ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { estimateCost } = require('../lib/cost-estimate'); +const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); +const { getClaudeDir } = require('../lib/utils'); + +const MAX_STDIN = 1024 * 1024; +const MAX_FILES_TRACKED = 200; +const RECENT_TOOLS_SIZE = 5; + +function toNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** + * Hash tool call for loop detection. + * Uses tool name + a key parameter (file_path for Edit/Write, first 80 chars of command for Bash). + */ +function hashToolCall(toolName, toolInput) { + const name = String(toolName || ''); + let key = ''; + if (name === 'Bash') { + key = String(toolInput?.command || '').slice(0, 80); + } else { + key = String(toolInput?.file_path || ''); + } + return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8); +} + +/** + * Extract modified file paths from tool input. + */ +function extractFilePaths(toolName, toolInput) { + const paths = []; + if (!toolInput || typeof toolInput !== 'object') return paths; + + const fp = toolInput.file_path; + if (fp && typeof fp === 'string') paths.push(fp); + + const edits = toolInput.edits; + if (Array.isArray(edits)) { + for (const edit of edits) { + if (edit?.file_path && typeof edit.file_path === 'string') { + paths.push(edit.file_path); + } + } + } + + return paths; +} + +/** + * Read cumulative cost for a session from the tail of costs.jsonl. + * Reads last 8KB to avoid scanning entire file. + */ +function readSessionCost(sessionId) { + try { + const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); + const stat = fs.statSync(costsPath); + const readSize = Math.min(stat.size, 8192); + const fd = fs.openSync(costsPath, 'r'); + try { + const buf = Buffer.alloc(readSize); + fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize)); + const lines = buf.toString('utf8').split('\n').filter(Boolean); + + let totalCost = 0; + let totalIn = 0; + let totalOut = 0; + for (const line of lines) { + try { + const row = JSON.parse(line); + if (row.session_id === sessionId || row.session_id === 'default') { + totalCost += toNumber(row.estimated_cost_usd); + totalIn += toNumber(row.input_tokens); + totalOut += toNumber(row.output_tokens); + } + } catch { + /* skip malformed lines */ + } + } + return { totalCost, totalIn, totalOut }; + } finally { + fs.closeSync(fd); + } + } catch { + return { totalCost: 0, totalIn: 0, totalOut: 0 }; + } +} + +/** + * @param {string} rawInput - Raw JSON string from stdin + * @returns {string} Pass-through + */ +function run(rawInput) { + try { + const input = rawInput.trim() ? JSON.parse(rawInput) : {}; + const toolName = String(input.tool_name || ''); + const toolInput = input.tool_input || {}; + + const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID); + + if (!sessionId) return rawInput; + + const now = new Date().toISOString(); + const bridge = readBridge(sessionId) || { + session_id: sessionId, + total_cost_usd: 0, + total_input_tokens: 0, + total_output_tokens: 0, + tool_count: 0, + files_modified_count: 0, + files_modified: [], + recent_tools: [], + first_timestamp: now, + last_timestamp: now, + context_remaining_pct: null + }; + + // Increment tool count + bridge.tool_count = (bridge.tool_count || 0) + 1; + bridge.last_timestamp = now; + if (!bridge.first_timestamp) bridge.first_timestamp = now; + + // Track modified files (Write/Edit/MultiEdit only) + const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName); + if (isWriteOp) { + const newPaths = extractFilePaths(toolName, toolInput); + const existing = new Set(bridge.files_modified || []); + for (const p of newPaths) { + if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) { + existing.add(p); + } + } + bridge.files_modified = [...existing]; + bridge.files_modified_count = existing.size; + } + + // Ring buffer for loop detection + const recent = bridge.recent_tools || []; + recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) }); + if (recent.length > RECENT_TOOLS_SIZE) recent.shift(); + bridge.recent_tools = recent; + + // Update cost from costs.jsonl tail + const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); + const costs = readSessionCost(envSessionId); + bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6; + bridge.total_input_tokens = costs.totalIn; + bridge.total_output_tokens = costs.totalOut; + + writeBridgeAtomic(sessionId, bridge); + } catch { + // Never block tool execution + } + + return rawInput; +} + +if (require.main === module) { + let data = ''; + 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, hashToolCall, extractFilePaths, readSessionCost }; diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js new file mode 100644 index 0000000000..fc20009956 --- /dev/null +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -0,0 +1,166 @@ +/** + * Tests for scripts/hooks/ecc-metrics-bridge.js + * + * Run with: node tests/hooks/ecc-metrics-bridge.test.js + */ + +const assert = require('assert'); + +const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge'); + +// 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-metrics-bridge.js ===\n'); + + let passed = 0; + let failed = 0; + + // hashToolCall tests + console.log('hashToolCall:'); + + if ( + test('returns 8-char hex string', () => { + const hash = hashToolCall('Bash', { command: 'ls' }); + assert.strictEqual(hash.length, 8); + assert.ok(/^[0-9a-f]{8}$/.test(hash), `Expected hex, got: ${hash}`); + }) + ) + passed++; + else failed++; + + if ( + test('different Bash commands produce different hashes', () => { + const h1 = hashToolCall('Bash', { command: 'ls' }); + const h2 = hashToolCall('Bash', { command: 'pwd' }); + assert.notStrictEqual(h1, h2); + }) + ) + passed++; + else failed++; + + if ( + test('different Edit file_paths produce different hashes', () => { + const h1 = hashToolCall('Edit', { file_path: 'a.js' }); + const h2 = hashToolCall('Edit', { file_path: 'b.js' }); + assert.notStrictEqual(h1, h2); + }) + ) + passed++; + else failed++; + + if ( + test('same inputs produce same hash (deterministic)', () => { + const h1 = hashToolCall('Write', { file_path: 'x.txt' }); + const h2 = hashToolCall('Write', { file_path: 'x.txt' }); + assert.strictEqual(h1, h2); + }) + ) + passed++; + else failed++; + + // extractFilePaths tests + console.log('\nextractFilePaths:'); + + if ( + test('Edit with file_path returns [file_path]', () => { + const paths = extractFilePaths('Edit', { file_path: 'a.js' }); + assert.deepStrictEqual(paths, ['a.js']); + }) + ) + passed++; + else failed++; + + if ( + test('MultiEdit with edits array returns all file_paths', () => { + const paths = extractFilePaths('MultiEdit', { + edits: [{ file_path: 'a.js' }, { file_path: 'b.js' }] + }); + assert.deepStrictEqual(paths, ['a.js', 'b.js']); + }) + ) + passed++; + else failed++; + + if ( + test('Bash with command returns empty array', () => { + const paths = extractFilePaths('Bash', { command: 'ls' }); + assert.deepStrictEqual(paths, []); + }) + ) + passed++; + else failed++; + + if ( + test('null toolInput returns empty array', () => { + const paths = extractFilePaths('Edit', null); + assert.deepStrictEqual(paths, []); + }) + ) + passed++; + else failed++; + + // readSessionCost tests + console.log('\nreadSessionCost:'); + + if ( + test('nonexistent session returns object with numeric fields', () => { + const result = readSessionCost('nonexistent-session-cost-test-xyz-999'); + assert.strictEqual(typeof result.totalCost, 'number'); + assert.strictEqual(typeof result.totalIn, 'number'); + assert.strictEqual(typeof result.totalOut, 'number'); + assert.ok(result.totalCost >= 0, 'totalCost should be non-negative'); + }) + ) + passed++; + else failed++; + + // run tests + console.log('\nrun:'); + + if ( + test('empty input returns empty input without crashing', () => { + const result = run(''); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + if ( + test('whitespace-only 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', tool_input: { command: 'ls' } }); + 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); From aec611a98beebcbb7bbf48eb6a99779710a7da44 Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:27 +0800 Subject: [PATCH 4/6] feat: add ecc-statusline hook with metrics display statusLine command showing model, current task, session cost, tool count, files modified, session duration, and context usage bar with color thresholds (green/yellow/orange/red). --- scripts/hooks/ecc-statusline.js | 160 +++++++++++++++++++++++++ tests/hooks/ecc-statusline.test.js | 180 +++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 scripts/hooks/ecc-statusline.js create mode 100644 tests/hooks/ecc-statusline.test.js diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js new file mode 100644 index 0000000000..c4b35f8540 --- /dev/null +++ b/scripts/hooks/ecc-statusline.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * ECC Statusline — statusLine command + * + * Displays: model | task | $cost Nt Nf Nm | dir ██░░ N% + * + * Registered in settings.json under "statusLine", not in hooks.json. + * Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); + +const AUTO_COMPACT_BUFFER_PCT = 16.5; + +/** + * Format duration from ISO timestamp to now. + * @param {string} isoTimestamp + * @returns {string} e.g. "5s", "12m", "1h23m" + */ +function formatDuration(isoTimestamp) { + if (!isoTimestamp) return '?'; + const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000); + if (elapsed < 0) return '?'; + if (elapsed < 60) return `${elapsed}s`; + const mins = Math.floor(elapsed / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`; +} + +/** + * Build context progress bar with ANSI colors. + * @param {number} remaining - Raw remaining percentage from Claude Code + * @returns {string} Colored bar string + */ +function buildContextBar(remaining) { + if (remaining == null) return ''; + + const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100); + const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining))); + + const filled = Math.floor(used / 10); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); + + if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`; + if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`; + if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`; + return ` \x1b[5;31m${bar} ${used}%\x1b[0m`; +} + +/** + * Read current in-progress task from todos directory. + * @param {string} sessionId + * @returns {string} Task activeForm text or empty string + */ +function readCurrentTask(sessionId) { + try { + const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const todosDir = path.join(claudeDir, 'todos'); + if (!fs.existsSync(todosDir)) return ''; + + const files = fs + .readdirSync(todosDir) + .filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json')) + .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime })) + .sort((a, b) => b.mtime - a.mtime); + + if (files.length === 0) return ''; + + const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8')); + const inProgress = todos.find(t => t.status === 'in_progress'); + return inProgress?.activeForm || ''; + } catch { + return ''; + } +} + +function runStatusline() { + let input = ''; + const stdinTimeout = setTimeout(() => process.exit(0), 3000); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => (input += chunk)); + process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const data = JSON.parse(input); + const model = data.model?.display_name || 'Claude'; + const dir = data.workspace?.current_dir || process.cwd(); + const session = data.session_id || ''; + const remaining = data.context_window?.remaining_percentage; + + const sessionId = sanitizeSessionId(session); + const bridge = sessionId ? readBridge(sessionId) : null; + + // Write context % back to bridge for context-monitor + if (sessionId && bridge && remaining != null) { + bridge.context_remaining_pct = remaining; + try { + writeBridgeAtomic(sessionId, bridge); + } catch { + /* best effort */ + } + } + + // Current task + const task = session ? readCurrentTask(session) : ''; + + // Metrics from bridge + let metricsStr = ''; + if (bridge) { + const parts = []; + if (bridge.total_cost_usd > 0) { + parts.push(`$${bridge.total_cost_usd.toFixed(2)}`); + } + if (bridge.tool_count > 0) { + parts.push(`${bridge.tool_count}t`); + } + if (bridge.files_modified_count > 0) { + parts.push(`${bridge.files_modified_count}f`); + } + const dur = formatDuration(bridge.first_timestamp); + if (dur !== '?') { + parts.push(dur); + } + if (parts.length > 0) { + metricsStr = `\x1b[36m${parts.join(' ')}\x1b[0m`; + } + } + + // Context bar + const ctx = buildContextBar(remaining); + + // Build output + const dirname = path.basename(dir); + const segments = [`\x1b[2m${model}\x1b[0m`]; + + if (task) { + segments.push(`\x1b[1m${task}\x1b[0m`); + } + if (metricsStr) { + segments.push(metricsStr); + } + segments.push(`\x1b[2m${dirname}\x1b[0m`); + + process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx); + } catch { + // Silent fail + } + }); +} + +module.exports = { formatDuration, buildContextBar, readCurrentTask }; + +if (require.main === module) runStatusline(); diff --git a/tests/hooks/ecc-statusline.test.js b/tests/hooks/ecc-statusline.test.js new file mode 100644 index 0000000000..fee5a4a3f6 --- /dev/null +++ b/tests/hooks/ecc-statusline.test.js @@ -0,0 +1,180 @@ +/** + * Tests for scripts/hooks/ecc-statusline.js + * + * Run with: node tests/hooks/ecc-statusline.test.js + */ + +const assert = require('assert'); + +const { formatDuration, buildContextBar, readCurrentTask } = require('../../scripts/hooks/ecc-statusline'); + +// 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-statusline.js ===\n'); + + let passed = 0; + let failed = 0; + + // formatDuration tests + console.log('formatDuration:'); + + if ( + test('null returns "?"', () => { + assert.strictEqual(formatDuration(null), '?'); + }) + ) + passed++; + else failed++; + + if ( + test('undefined returns "?"', () => { + assert.strictEqual(formatDuration(undefined), '?'); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 30 seconds ago ends with "s"', () => { + const ts = new Date(Date.now() - 30 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.endsWith('s'), `Expected ending in "s", got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 5 minutes ago ends with "m"', () => { + const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.endsWith('m'), `Expected ending in "m", got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 90 minutes ago contains "h"', () => { + const ts = new Date(Date.now() - 90 * 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.includes('h'), `Expected "h" in result, got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('future timestamp returns "?"', () => { + const ts = new Date(Date.now() + 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.strictEqual(result, '?'); + }) + ) + passed++; + else failed++; + + // buildContextBar tests + console.log('\nbuildContextBar:'); + + if ( + test('null returns empty string', () => { + assert.strictEqual(buildContextBar(null), ''); + }) + ) + passed++; + else failed++; + + if ( + test('undefined returns empty string', () => { + assert.strictEqual(buildContextBar(undefined), ''); + }) + ) + passed++; + else failed++; + + if ( + test('80% remaining contains green ANSI code', () => { + const bar = buildContextBar(80); + assert.ok(bar.includes('\x1b[32m'), `Expected green ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('50% remaining contains yellow ANSI code', () => { + const bar = buildContextBar(50); + assert.ok(bar.includes('\x1b[33m'), `Expected yellow ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('20% remaining contains red blink ANSI code', () => { + const bar = buildContextBar(20); + assert.ok(bar.includes('\x1b[5;31m'), `Expected red blink ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('context bar contains block characters', () => { + const bar = buildContextBar(60); + assert.ok(bar.includes('\u2588') || bar.includes('\u2591'), 'Expected block characters in bar'); + }) + ) + passed++; + else failed++; + + if ( + test('context bar contains percentage', () => { + const bar = buildContextBar(70); + assert.ok(bar.includes('%'), 'Expected percentage in bar'); + }) + ) + passed++; + else failed++; + + // readCurrentTask tests + console.log('\nreadCurrentTask:'); + + if ( + test('nonexistent session returns empty string', () => { + const result = readCurrentTask('nonexistent-session-xyz-999'); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + if ( + test('empty string session returns empty string', () => { + const result = readCurrentTask(''); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0); From cf79534c2cdd111d1cb9914d940ca4ed3a0339fb Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:35 +0800 Subject: [PATCH 5/6] 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. --- scripts/hooks/ecc-context-monitor.js | 239 ++++++++++++++++++++++++ tests/hooks/ecc-context-monitor.test.js | 238 +++++++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 scripts/hooks/ecc-context-monitor.js create mode 100644 tests/hooks/ecc-context-monitor.test.js diff --git a/scripts/hooks/ecc-context-monitor.js b/scripts/hooks/ecc-context-monitor.js new file mode 100644 index 0000000000..c3b28800c4 --- /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 0000000000..bbecc4ed64 --- /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); From 9f9467f826c56629d41dfa5e9c34a20ff0ee585e Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:42 +0800 Subject: [PATCH 6/6] chore: register new hooks in hooks.json and update statusline example Add post:ecc-metrics-bridge and post:ecc-context-monitor to PostToolUse hooks. Update examples/statusline.json with ECC-native statusline config. --- examples/statusline.json | 19 ++++++++++--------- hooks/hooks.json | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/examples/statusline.json b/examples/statusline.json index 4fa84e8185..614136094f 100644 --- a/examples/statusline.json +++ b/examples/statusline.json @@ -1,19 +1,20 @@ { "statusLine": { "type": "command", - "command": "input=$(cat); user=$(whoami); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir' | sed \"s|$HOME|~|g\"); model=$(echo \"$input\" | jq -r '.model.display_name'); time=$(date +%H:%M); remaining=$(echo \"$input\" | jq -r '.context_window.remaining_percentage // empty'); transcript=$(echo \"$input\" | jq -r '.transcript_path'); todo_count=$([ -f \"$transcript\" ] && grep -c '\"type\":\"todo\"' \"$transcript\" 2>/dev/null || echo 0); cd \"$(echo \"$input\" | jq -r '.workspace.current_dir')\" 2>/dev/null; branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); status=''; [ -n \"$branch\" ] && { [ -n \"$(git status --porcelain 2>/dev/null)\" ] && status='*'; }; B='\\033[38;2;30;102;245m'; G='\\033[38;2;64;160;43m'; Y='\\033[38;2;223;142;29m'; M='\\033[38;2;136;57;239m'; C='\\033[38;2;23;146;153m'; R='\\033[0m'; T='\\033[38;2;76;79;105m'; printf \"${C}${user}${R}:${B}${cwd}${R}\"; [ -n \"$branch\" ] && printf \" ${G}${branch}${Y}${status}${R}\"; [ -n \"$remaining\" ] && printf \" ${M}ctx:${remaining}%%${R}\"; printf \" ${T}${model}${R} ${Y}${time}${R}\"; [ \"$todo_count\" -gt 0 ] && printf \" ${C}todos:${todo_count}${R}\"; echo", - "description": "Custom status line showing: user:path branch* ctx:% model time todos:N" + "command": "node \"/scripts/hooks/ecc-statusline.js\"", + "description": "ECC statusline: model | task | $cost tools files duration | dir | context bar" }, "_comments": { + "setup": "Replace with your ECC installation path. For plugin installs, use the resolved path from CLAUDE_PLUGIN_ROOT.", + "display": "Shows model name, current task, session cost, tool count, files modified, session duration, directory, and context usage bar with color thresholds.", "colors": { - "B": "Blue - directory path", - "G": "Green - git branch", - "Y": "Yellow - dirty status, time", - "M": "Magenta - context remaining", - "C": "Cyan - username, todos", - "T": "Gray - model name" + "green": "Context used < 50%", + "yellow": "Context used < 65%", + "orange": "Context used < 80%", + "red_blink": "Context used >= 80%" }, - "output_example": "affoon:~/projects/myapp main* ctx:73% sonnet-4.6 14:30 todos:3", + "output_example": "Opus 4.6 | Fixing auth bug | $1.23 47t 5f 15m | myproject ███████░░░ 68%", + "dependencies": "Reads bridge file from ecc-metrics-bridge.js PostToolUse hook. Both must be installed for full metrics display.", "usage": "Copy the statusLine object to your ~/.claude/settings.json" } } diff --git a/hooks/hooks.json b/hooks/hooks.json index ceb3e6c8a3..73e29e7499 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -219,6 +219,30 @@ ], "description": "Capture tool use results for continuous learning", "id": "post:observe:continuous-learning" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-metrics-bridge scripts/hooks/ecc-metrics-bridge.js minimal,standard,strict", + "timeout": 10 + } + ], + "description": "Maintain running session metrics aggregate for statusline and context monitor", + "id": "post:ecc-metrics-bridge" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-context-monitor scripts/hooks/ecc-context-monitor.js standard,strict", + "timeout": 10 + } + ], + "description": "Inject agent warnings on context exhaustion, high cost, scope creep, or tool loops", + "id": "post:ecc-context-monitor" } ], "PostToolUseFailure": [