diff --git a/examples/statusline.json b/examples/statusline.json index 4fa84e81..61413609 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 ad709b3c..1b9420ac 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\\\"],[\\\"marketplaces\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplaces\\\",\\\"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\\\"],[\\\"marketplaces\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplaces\\\",\\\"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": [ diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 817ff77a..a3f2f896 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -8,11 +8,9 @@ '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 { sanitizeSessionId } = require('../lib/session-bridge'); const MAX_STDIN = 1024 * 1024; let raw = ''; @@ -22,23 +20,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) { @@ -55,7 +36,11 @@ process.stdin.on('end', () => { const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0); const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown'); - const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); + const sessionId = + sanitizeSessionId(input.session_id) || + sanitizeSessionId(process.env.ECC_SESSION_ID) || + sanitizeSessionId(process.env.CLAUDE_SESSION_ID) || + 'default'; const metricsDir = path.join(getClaudeDir(), 'metrics'); ensureDir(metricsDir); @@ -66,7 +51,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`); diff --git a/scripts/hooks/ecc-context-monitor.js b/scripts/hooks/ecc-context-monitor.js new file mode 100644 index 00000000..a3c0be8b --- /dev/null +++ b/scripts/hooks/ecc-context-monitor.js @@ -0,0 +1,242 @@ +#!/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 }; diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js new file mode 100644 index 00000000..d7f97cfa --- /dev/null +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -0,0 +1,198 @@ +#!/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 { 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; +const HASH_INPUT_LIMIT = 2048; + +function toNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function stableStringify(value, depth = 0) { + if (depth > 4) return '[depth-limit]'; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(item => stableStringify(item, depth + 1)).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${stableStringify(value[key], depth + 1)}`) + .join(',')}}`; +} + +/** + * Hash tool call for loop detection. + * Uses tool name + a key parameter when available, otherwise a stable input digest. + */ +function hashToolCall(toolName, toolInput) { + const name = String(toolName || ''); + let key = ''; + if (name === 'Bash') { + key = String(toolInput?.command || '').slice(0, 160); + } else if (toolInput?.file_path) { + key = String(toolInput.file_path); + } else { + key = stableStringify(toolInput || {}).slice(0, HASH_INPUT_LIMIT); + } + 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) { + 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 costs = readSessionCost(sessionId); + 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, stableStringify }; diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js new file mode 100644 index 00000000..825b8f4d --- /dev/null +++ b/scripts/hooks/ecc-statusline.js @@ -0,0 +1,168 @@ +#!/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; +const MAX_STDIN = 1024 * 1024; + +/** + * 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 || remaining === undefined) 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 safeSessionId = sanitizeSessionId(sessionId); + if (!safeSessionId) return ''; + + 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(safeSessionId) && 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 => { + if (input.length < MAX_STDIN) { + input += chunk.substring(0, MAX_STDIN - input.length); + } + }); + 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 && remaining !== undefined) { + bridge.context_remaining_pct = remaining; + try { + writeBridgeAtomic(sessionId, bridge); + } catch { + /* best effort */ + } + } + + // Current task + const task = sessionId ? readCurrentTask(sessionId) : ''; + + // 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, MAX_STDIN }; + +if (require.main === module) runStatusline(); diff --git a/scripts/lib/cost-estimate.js b/scripts/lib/cost-estimate.js new file mode 100644 index 00000000..a1651a8c --- /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 00000000..aceae9cb --- /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/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index ee834465..4a1f6fef 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -152,6 +152,28 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + // 7. Uses sanitized hook input session_id when environment session IDs are absent + (test('uses input session_id for session correlation when env vars are absent', () => { + const tmpHome = makeTempDir(); + const input = { + session_id: 'hook-session-abc', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 120, output_tokens: 30 }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + ECC_SESSION_ID: '', + CLAUDE_SESSION_ID: '', + }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.session_id, 'hook-session-abc', 'Expected input session_id to be recorded'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/hooks/ecc-context-monitor.test.js b/tests/hooks/ecc-context-monitor.test.js new file mode 100644 index 00000000..bbecc4ed --- /dev/null +++ b/tests/hooks/ecc-context-monitor.test.js @@ -0,0 +1,238 @@ +/** + * Tests for scripts/hooks/ecc-context-monitor.js + * + * Run with: node tests/hooks/ecc-context-monitor.test.js + */ + +const assert = require('assert'); + +const { run, evaluateConditions, detectLoop, severityLabel } = require('../../scripts/hooks/ecc-context-monitor'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing ecc-context-monitor.js ===\n'); + + let passed = 0; + let failed = 0; + + // evaluateConditions — context warnings + console.log('evaluateConditions (context):'); + + if ( + test('remaining 20% triggers CRITICAL context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 20 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.ok(ctx, 'Expected a context warning'); + assert.strictEqual(ctx.severity, 3); + assert.ok(ctx.message.includes('CRITICAL'), 'Message should contain CRITICAL'); + }) + ) + passed++; + else failed++; + + if ( + test('remaining 30% triggers WARNING context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 30 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.ok(ctx, 'Expected a context warning'); + assert.strictEqual(ctx.severity, 2); + assert.ok(ctx.message.includes('WARNING'), 'Message should contain WARNING'); + }) + ) + passed++; + else failed++; + + if ( + test('remaining 50% triggers no context warning', () => { + const warnings = evaluateConditions({ context_remaining_pct: 50 }); + const ctx = warnings.find(w => w.type === 'context'); + assert.strictEqual(ctx, undefined); + }) + ) + passed++; + else failed++; + + // evaluateConditions — cost warnings + console.log('\nevaluateConditions (cost):'); + + if ( + test('cost $55 triggers CRITICAL cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 55 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 3); + assert.ok(cost.message.includes('CRITICAL'), 'Message should contain CRITICAL'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $12 triggers WARNING cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 12 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 2); + assert.ok(cost.message.includes('WARNING'), 'Message should contain WARNING'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $6 triggers NOTICE cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 6 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.ok(cost, 'Expected a cost warning'); + assert.strictEqual(cost.severity, 1); + assert.ok(cost.message.includes('NOTICE'), 'Message should contain NOTICE'); + }) + ) + passed++; + else failed++; + + if ( + test('cost $2 triggers no cost warning', () => { + const warnings = evaluateConditions({ total_cost_usd: 2 }); + const cost = warnings.find(w => w.type === 'cost'); + assert.strictEqual(cost, undefined); + }) + ) + passed++; + else failed++; + + // evaluateConditions — scope warnings + console.log('\nevaluateConditions (scope):'); + + if ( + test('25 files triggers scope WARNING', () => { + const warnings = evaluateConditions({ files_modified_count: 25 }); + const scope = warnings.find(w => w.type === 'scope'); + assert.ok(scope, 'Expected a scope warning'); + assert.strictEqual(scope.severity, 2); + assert.ok(scope.message.includes('SCOPE'), 'Message should contain SCOPE'); + }) + ) + passed++; + else failed++; + + if ( + test('10 files triggers no scope warning', () => { + const warnings = evaluateConditions({ files_modified_count: 10 }); + const scope = warnings.find(w => w.type === 'scope'); + assert.strictEqual(scope, undefined); + }) + ) + passed++; + else failed++; + + // detectLoop tests + console.log('\ndetectLoop:'); + + if ( + test('3 identical entries returns detected true', () => { + const entries = [ + { tool: 'Bash', hash: 'aabbccdd' }, + { tool: 'Bash', hash: 'aabbccdd' }, + { tool: 'Bash', hash: 'aabbccdd' } + ]; + const result = detectLoop(entries); + assert.strictEqual(result.detected, true); + assert.strictEqual(result.tool, 'Bash'); + assert.ok(result.count >= 3); + }) + ) + passed++; + else failed++; + + if ( + test('all different entries returns detected false', () => { + const entries = [ + { tool: 'Bash', hash: '11111111' }, + { tool: 'Edit', hash: '22222222' }, + { tool: 'Write', hash: '33333333' } + ]; + const result = detectLoop(entries); + assert.strictEqual(result.detected, false); + }) + ) + passed++; + else failed++; + + if ( + test('empty array returns detected false', () => { + const result = detectLoop([]); + assert.strictEqual(result.detected, false); + }) + ) + passed++; + else failed++; + + // severityLabel tests + console.log('\nseverityLabel:'); + + if ( + test('severity 3 returns critical', () => { + assert.strictEqual(severityLabel(3), 'critical'); + }) + ) + passed++; + else failed++; + + if ( + test('severity 2 returns warning', () => { + assert.strictEqual(severityLabel(2), 'warning'); + }) + ) + passed++; + else failed++; + + if ( + test('severity 1 returns notice', () => { + assert.strictEqual(severityLabel(1), 'notice'); + }) + ) + passed++; + else failed++; + + // run tests + console.log('\nrun:'); + + if ( + test('empty input returns input unchanged', () => { + const result = run(''); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + if ( + test('input without session_id returns input unchanged', () => { + const input = JSON.stringify({ tool_name: 'Bash' }); + const result = run(input); + assert.strictEqual(result, input); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js new file mode 100644 index 00000000..ac7631ca --- /dev/null +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -0,0 +1,219 @@ +/** + * Tests for scripts/hooks/ecc-metrics-bridge.js + * + * Run with: node tests/hooks/ecc-metrics-bridge.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +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 makeTempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-metrics-bridge-test-')); +} + +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++; + + if ( + test('non-file tools hash by stable input to avoid false loop collisions', () => { + const h1 = hashToolCall('Glob', { pattern: '**/*.js', path: '/repo/a' }); + const h2 = hashToolCall('Glob', { pattern: '**/*.md', path: '/repo/a' }); + const h3 = hashToolCall('Glob', { path: '/repo/a', pattern: '**/*.js' }); + assert.notStrictEqual(h1, h2); + assert.strictEqual(h1, h3); + }) + ) + 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++; + + if ( + test('readSessionCost does not include unrelated default-session rows', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + [ + JSON.stringify({ session_id: 'default', estimated_cost_usd: 50, input_tokens: 1000, output_tokens: 2000 }), + JSON.stringify({ session_id: 'target-session', estimated_cost_usd: 1.25, input_tokens: 10, output_tokens: 20 }) + ].join('\n') + '\n', + 'utf8' + ); + const result = readSessionCost('target-session'); + assert.strictEqual(result.totalCost, 1.25); + assert.strictEqual(result.totalIn, 10); + assert.strictEqual(result.totalOut, 20); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + 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); diff --git a/tests/hooks/ecc-statusline.test.js b/tests/hooks/ecc-statusline.test.js new file mode 100644 index 00000000..f7cfacd7 --- /dev/null +++ b/tests/hooks/ecc-statusline.test.js @@ -0,0 +1,213 @@ +/** + * Tests for scripts/hooks/ecc-statusline.js + * + * Run with: node tests/hooks/ecc-statusline.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +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 makeTempConfig() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-statusline-test-')); +} + +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++; + + if ( + test('reads in-progress task for sanitized session ID only', () => { + const tmpConfig = makeTempConfig(); + const originalConfig = process.env.CLAUDE_CONFIG_DIR; + try { + process.env.CLAUDE_CONFIG_DIR = tmpConfig; + const todosDir = path.join(tmpConfig, 'todos'); + fs.mkdirSync(todosDir, { recursive: true }); + fs.writeFileSync( + path.join(todosDir, 'safe-session-agent-main.json'), + JSON.stringify([{ status: 'in_progress', activeForm: 'Fix auth flow' }]), + 'utf8' + ); + + assert.strictEqual(readCurrentTask('safe-session'), 'Fix auth flow'); + assert.strictEqual(readCurrentTask('../safe-session'), ''); + } finally { + if (originalConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalConfig; + fs.rmSync(tmpConfig, { recursive: true, force: true }); + } + }) + ) + 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/cost-estimate.test.js b/tests/lib/cost-estimate.test.js new file mode 100644 index 00000000..bcb5906b --- /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 00000000..60841c9b --- /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);