From b88f2cd730984ab0788f4c447d12c8ff761d7923 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 16 Apr 2026 05:09:01 -0700 Subject: [PATCH] feat: ghost detectors, health grade, @-import expansion Expanded the optimize engine with new detectors and scoring: 1. Health score + letter grade (A-F) in optimize header. Weighted per-impact with caps. Gives users an instant "is my setup healthy" read that doubles as a shareable number. 2. Urgency score replaces impact-enum sort. Weighted 0.7 * impact + 0.3 * normalized tokens. Produces better-ranked findings. 3. Three new ghost detectors: - Ghost agents: files in ~/.claude/agents/ never invoked via Agent/Task tool - Ghost skills: SKILL.md directories never triggered - Ghost slash-commands: ~/.claude/commands/ files never referenced in user messages 4. @-import chain expansion for CLAUDE.md. Recursively follows @path/to/file imports (max depth 5) so bloat detection counts transitive load, not just the base file. Fixes undercounting for users with modular CLAUDE.md setups. 9 new tests covering health scoring and import expansion. --- src/dashboard.tsx | 14 ++- src/optimize.ts | 257 ++++++++++++++++++++++++++++++++++++----- tests/optimize.test.ts | 80 +++++++++++++ 3 files changed, 322 insertions(+), 29 deletions(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 4d922873..65876146 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -470,16 +470,24 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find ) } -function OptimizeView({ findings, costRate, projects, label, width }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number }) { +const GRADE_COLORS: Record = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' } + +function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string }) { const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) const totalCost = totalTokens * costRate const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + const gradeColor = GRADE_COLORS[healthGrade] ?? DIM return ( - CodeBurn Optimize {label} + + CodeBurn Optimize + {label} Setup: + {healthGrade} + ({healthScore}/100) + Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend) {findings.map((f, i) => )} @@ -652,7 +660,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, {view === 'optimize' && optimizeResult - ? + ? : } diff --git a/src/optimize.ts b/src/optimize.ts index f29a904a..e80744a6 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -39,9 +39,13 @@ export type WasteFinding = { fix: WasteAction } +export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F' + export type OptimizeResult = { findings: WasteFinding[] costRate: number + healthScore: number + healthGrade: HealthGrade } type ToolCall = { @@ -61,6 +65,7 @@ type ScanData = { projectCwds: Set apiCalls: ApiCallMeta[] versions: Set + userMessages: string[] } const IMPACT_ORDER: Record = { high: 3, medium: 2, low: 1 } @@ -84,6 +89,7 @@ type ScanFileResult = { cwds: string[] apiCalls: ApiCallMeta[] versions: string[] + userMessages: string[] } async function scanJsonlFile( @@ -94,12 +100,13 @@ async function scanJsonlFile( let content: string try { content = await readFile(filePath, 'utf-8') - } catch { return { calls: [], cwds: [], apiCalls: [], versions: [] } } + } catch { return { calls: [], cwds: [], apiCalls: [], versions: [], userMessages: [] } } const calls: ToolCall[] = [] const cwds: string[] = [] const apiCalls: ApiCallMeta[] = [] const versions: string[] = [] + const userMessages: string[] = [] const sessionId = basename(filePath, '.jsonl') for (const line of content.split('\n')) { @@ -110,6 +117,21 @@ async function scanJsonlFile( if (entry.cwd && typeof entry.cwd === 'string') cwds.push(entry.cwd) if (entry.version && typeof entry.version === 'string') versions.push(entry.version) + if (entry.type === 'user') { + const msg = entry.message as Record | undefined + const msgContent = msg?.content + if (typeof msgContent === 'string') { + userMessages.push(msgContent) + } else if (Array.isArray(msgContent)) { + for (const block of msgContent) { + if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { + userMessages.push(block.text) + } + } + } + continue + } + if (entry.type !== 'assistant') continue if (dateRange && typeof entry.timestamp === 'string') { @@ -139,7 +161,7 @@ async function scanJsonlFile( } } - return { calls, cwds, apiCalls, versions } + return { calls, cwds, apiCalls, versions, userMessages } } async function scanSessions(dateRange?: DateRange): Promise { @@ -148,19 +170,21 @@ async function scanSessions(dateRange?: DateRange): Promise { const allCwds = new Set() const allApiCalls: ApiCallMeta[] = [] const allVersions = new Set() + const allUserMessages: string[] = [] for (const source of sources) { const files = await collectJsonlFiles(source.path) for (const file of files) { - const { calls, cwds, apiCalls, versions } = await scanJsonlFile(file, source.project, dateRange) + const { calls, cwds, apiCalls, versions, userMessages } = await scanJsonlFile(file, source.project, dateRange) allCalls.push(...calls) for (const cwd of cwds) allCwds.add(cwd) allApiCalls.push(...apiCalls) for (const v of versions) if (v) allVersions.add(v) + allUserMessages.push(...userMessages) } } - return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, versions: allVersions } + return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, versions: allVersions, userMessages: allUserMessages } } function detectJunkReads(calls: ToolCall[]): WasteFinding | null { @@ -378,8 +402,36 @@ function detectMissingClaudeignore(projectCwds: Set): WasteFinding | nul } } +const MAX_IMPORT_DEPTH = 5 +const IMPORT_PATTERN = /^@([^\s]+)/gm + +function expandImports(filePath: string, seen: Set, depth: number): { totalLines: number; importedFiles: number } { + if (depth > MAX_IMPORT_DEPTH || seen.has(filePath)) return { totalLines: 0, importedFiles: 0 } + seen.add(filePath) + let content: string + try { content = readFileSync(filePath, 'utf-8') } catch { return { totalLines: 0, importedFiles: 0 } } + + const lines = content.split('\n').length + let totalLines = lines + let importedFiles = 0 + const dir = join(filePath, '..') + + const matches = content.matchAll(IMPORT_PATTERN) + for (const match of matches) { + const rawPath = match[1] + if (!rawPath || rawPath.startsWith('http') || rawPath.includes('@')) continue + const resolved = rawPath.startsWith('/') ? rawPath : join(dir, rawPath) + if (!existsSync(resolved)) continue + const nested = expandImports(resolved, seen, depth + 1) + totalLines += nested.totalLines + importedFiles += 1 + nested.importedFiles + } + + return { totalLines, importedFiles } +} + function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | null { - const bloated: { path: string; lines: number }[] = [] + const bloated: { path: string; lines: number; expandedLines: number; imports: number }[] = [] for (const cwd of projectCwds) { for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { @@ -388,9 +440,10 @@ function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | null { try { const content = readFileSync(fullPath, 'utf-8') const lineCount = content.split('\n').length - if (lineCount > CLAUDEMD_HEALTHY_LINES) { + const { totalLines, importedFiles } = expandImports(fullPath, new Set(), 0) + if (totalLines > CLAUDEMD_HEALTHY_LINES) { const short = cwd.startsWith(homedir()) ? '~' + cwd.slice(homedir().length) : cwd - bloated.push({ path: `${short}/${name}`, lines: lineCount }) + bloated.push({ path: `${short}/${name}`, lines: lineCount, expandedLines: totalLines, imports: importedFiles }) } } catch { continue } } @@ -398,23 +451,26 @@ function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | null { if (bloated.length === 0) return null - const sorted = bloated.sort((a, b) => b.lines - a.lines) + const sorted = bloated.sort((a, b) => b.expandedLines - a.expandedLines) const worst = sorted[0] - const totalExtraLines = sorted.reduce((s, b) => s + (b.lines - CLAUDEMD_HEALTHY_LINES), 0) + const totalExtraLines = sorted.reduce((s, b) => s + (b.expandedLines - CLAUDEMD_HEALTHY_LINES), 0) const tokensPerLine = 25 const tokensSaved = totalExtraLines * tokensPerLine - const list = sorted.slice(0, 3).map(b => `${b.path} (${b.lines} lines)`).join(', ') + const list = sorted.slice(0, 3).map(b => { + const importNote = b.imports > 0 ? ` + ${b.imports} imported` : '' + return `${b.path} (${b.expandedLines} lines${importNote})` + }).join(', ') return { title: 'TRIM BLOATED CLAUDE.md', - explanation: `${list}. Every line loads into every API call as context. Beyond ${CLAUDEMD_HEALTHY_LINES} lines, the extra ~${totalExtraLines} lines cost ~${formatTokens(tokensSaved)} tokens per call.`, - impact: worst.lines > 400 ? 'high' : 'medium', + explanation: `${list}. Every line loads into every API call as context, including @-imports. Beyond ${CLAUDEMD_HEALTHY_LINES} lines, the extra ~${totalExtraLines} lines cost ~${formatTokens(tokensSaved)} tokens per call.`, + impact: worst.expandedLines > 400 ? 'high' : 'medium', tokensSaved, fix: { type: 'paste', label: 'Ask Claude to trim it:', - text: 'Review CLAUDE.md and cut it to under 200 lines. Remove anything Claude can figure out from the code itself: file paths, architecture, imports. Keep only: rules, gotchas, and non-obvious conventions.', + text: 'Review CLAUDE.md and all @-imported files. Cut total expanded content to under 200 lines. Remove anything Claude can figure out from the code itself: file paths, architecture, imports. Keep only: rules, gotchas, and non-obvious conventions.', }, } } @@ -503,6 +559,126 @@ function detectCacheBloat(apiCalls: ApiCallMeta[]): WasteFinding | null { } } +const TOKENS_PER_AGENT_DEF = 80 +const TOKENS_PER_COMMAND_DEF = 60 +const SKILL_FRONTMATTER_TOKENS = 80 + +async function listMarkdownFiles(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + return entries.filter(e => e.endsWith('.md')).map(e => e.replace(/\.md$/, '')) + } catch { return [] } +} + +async function listSkillDirs(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + const names: string[] = [] + for (const entry of entries) { + if (existsSync(join(dir, entry, 'SKILL.md'))) names.push(entry) + } + return names + } catch { return [] } +} + +async function detectGhostAgents(calls: ToolCall[]): Promise { + const agentDir = join(homedir(), '.claude', 'agents') + const defined = await listMarkdownFiles(agentDir) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Agent' && call.name !== 'Task') continue + const subType = call.input.subagent_type as string | undefined + if (subType) invoked.add(subType) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_AGENT_DEF + const list = ghosts.slice(0, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '') + + return { + title: 'REMOVE UNUSED CUSTOM AGENTS', + explanation: `${ghosts.length} agent definition${ghosts.length > 1 ? 's' : ''} in ~/.claude/agents/ never invoked: ${list}. Each agent adds ~${TOKENS_PER_AGENT_DEF} tokens of description to the Task tool schema on every session.`, + impact: ghosts.length >= 5 ? 'high' : ghosts.length >= 2 ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused agent${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, 10).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'), + }, + } +} + +async function detectGhostSkills(calls: ToolCall[]): Promise { + const skillDir = join(homedir(), '.claude', 'skills') + const defined = await listSkillDirs(skillDir) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Skill') continue + const skillName = (call.input.skill as string) || (call.input.name as string) + if (skillName) invoked.add(skillName) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0 || ghosts.length < 3) return null + + const tokensSaved = ghosts.length * SKILL_FRONTMATTER_TOKENS + const list = ghosts.slice(0, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '') + + return { + title: 'REMOVE UNUSED SKILLS', + explanation: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} in ~/.claude/skills/ never invoked: ${list}. Each skill's frontmatter adds ~${SKILL_FRONTMATTER_TOKENS} tokens to the Skill tool invocation index on every session.`, + impact: ghosts.length >= 10 ? 'high' : ghosts.length >= 5 ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused skill${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, 10).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'), + }, + } +} + +async function detectGhostCommands(userMessages: string[]): Promise { + const cmdDir = join(homedir(), '.claude', 'commands') + const defined = await listMarkdownFiles(cmdDir) + if (defined.length === 0) return null + + const invoked = new Set() + const cmdPattern = /([^<]+)<\/command-name>|\/(\w+[-\w]*)/g + for (const msg of userMessages) { + const matches = msg.matchAll(cmdPattern) + for (const m of matches) { + const name = (m[1] || m[2] || '').replace(/^\//, '') + if (name) invoked.add(name) + } + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_COMMAND_DEF + const list = ghosts.slice(0, 5).join(', ') + (ghosts.length > 5 ? `, +${ghosts.length - 5} more` : '') + + return { + title: 'REMOVE UNUSED SLASH COMMANDS', + explanation: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} in ~/.claude/commands/ never used: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`, + impact: ghosts.length >= 10 ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused command${ghosts.length > 1 ? 's' : ''}:`, + text: ghosts.slice(0, 10).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'), + }, + } +} + function detectBashBloat(): WasteFinding | null { const current = process.env['BASH_MAX_OUTPUT_LENGTH'] if (current && parseInt(current, 10) <= 15000) return null @@ -591,6 +767,8 @@ function renderFinding(n: number, f: WasteFinding, costRate: number, W: number): return lines } +const GRADE_COLORS: Record = { A: GREEN, B: GREEN, C: GOLD, D: ORANGE, F: '#F55B5B' } + function renderOptimize( findings: WasteFinding[], costRate: number, @@ -598,6 +776,8 @@ function renderOptimize( periodCost: number, sessionCount: number, callCount: number, + healthScore: number, + healthGrade: HealthGrade, ): string { const lines: string[] = [] const W = 62 @@ -611,6 +791,7 @@ function renderOptimize( `${sessionCount} sessions`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), + `Setup: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)} ${chalk.dim(`(${healthScore}/100)`)}`, ].join(chalk.hex(DIM)(' '))) lines.push('') @@ -628,9 +809,7 @@ function renderOptimize( lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens (~${formatCost(totalCost)}, ~${pct}% of spend)`)) lines.push('') - const sorted = findings.sort((a, b) => - (IMPACT_ORDER[b.impact] ?? 0) - (IMPACT_ORDER[a.impact] ?? 0) || b.tokensSaved - a.tokensSaved - ) + const sorted = findings for (let i = 0; i < sorted.length; i++) { lines.push(...renderFinding(i + 1, sorted[i], costRate, W)) @@ -643,15 +822,33 @@ function renderOptimize( return lines.join('\n') } +function computeHealth(findings: WasteFinding[]): { score: number; grade: HealthGrade } { + if (findings.length === 0) return { score: 100, grade: 'A' } + + const impactWeight: Record = { high: 15, medium: 7, low: 3 } + let penalty = 0 + for (const f of findings) penalty += impactWeight[f.impact] ?? 0 + + const score = Math.max(0, 100 - Math.min(80, penalty)) + const grade: HealthGrade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 30 ? 'D' : 'F' + return { score, grade } +} + +function urgencyScore(f: WasteFinding): number { + const impactWeight: Record = { high: 1, medium: 0.5, low: 0.2 } + const normalizedTokens = Math.min(1, f.tokensSaved / 500_000) + return (impactWeight[f.impact] ?? 0) * 0.7 + normalizedTokens * 0.3 +} + export async function scanAndDetect( projects: ProjectSummary[], dateRange?: DateRange, ): Promise { const costRate = computeInputCostRate(projects) - const { toolCalls, projectCwds, apiCalls } = await scanSessions(dateRange) + const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) const findings: WasteFinding[] = [] - const detectors = [ + const syncDetectors = [ () => detectCacheBloat(apiCalls), () => detectLowReadEditRatio(toolCalls), () => detectJunkReads(toolCalls), @@ -661,17 +858,25 @@ export async function scanAndDetect( () => detectBloatedClaudeMd(projectCwds), () => detectBashBloat(), ] - - for (const detect of detectors) { + for (const detect of syncDetectors) { const finding = detect() if (finding) findings.push(finding) } - findings.sort((a, b) => - (IMPACT_ORDER[b.impact] ?? 0) - (IMPACT_ORDER[a.impact] ?? 0) || b.tokensSaved - a.tokensSaved - ) + const asyncDetectors = [ + () => detectGhostAgents(toolCalls), + () => detectGhostSkills(toolCalls), + () => detectGhostCommands(userMessages), + ] + for (const detect of asyncDetectors) { + const finding = await detect() + if (finding) findings.push(finding) + } - return { findings, costRate } + findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) + + const { score, grade } = computeHealth(findings) + return { findings, costRate, healthScore: score, healthGrade: grade } } export async function runOptimize( @@ -686,11 +891,11 @@ export async function runOptimize( process.stderr.write(chalk.dim(' Scanning sessions for waste patterns...\n')) - const { findings, costRate } = await scanAndDetect(projects, dateRange) + const { findings, costRate, healthScore, healthGrade } = await scanAndDetect(projects, dateRange) const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade) console.log(output) } diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index c8514eae..b77ba25d 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -236,3 +236,83 @@ describe('optimize: read:edit ratio detection', () => { expect(edits).toBe(0) }) }) + +function computeHealthLogic(impacts: Array<'high' | 'medium' | 'low'>): { score: number; grade: string } { + if (impacts.length === 0) return { score: 100, grade: 'A' } + const impactWeight: Record = { high: 15, medium: 7, low: 3 } + let penalty = 0 + for (const i of impacts) penalty += impactWeight[i] ?? 0 + const score = Math.max(0, 100 - Math.min(80, penalty)) + const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 30 ? 'D' : 'F' + return { score, grade } +} + +describe('optimize: health score and grade', () => { + it('returns A with no findings', () => { + const { score, grade } = computeHealthLogic([]) + expect(score).toBe(100) + expect(grade).toBe('A') + }) + + it('one low finding keeps grade at A', () => { + const { score, grade } = computeHealthLogic(['low']) + expect(score).toBe(97) + expect(grade).toBe('A') + }) + + it('two high findings drop to C', () => { + const { score, grade } = computeHealthLogic(['high', 'high']) + expect(score).toBe(70) + expect(grade).toBe('C') + }) + + it('caps penalty at 80 to prevent going below 20', () => { + const impacts = Array(20).fill('high' as const) + const { score } = computeHealthLogic(impacts) + expect(score).toBe(20) + }) + + it('mix produces D grade', () => { + const { score, grade } = computeHealthLogic(['high', 'high', 'medium', 'medium', 'low']) + expect(score).toBe(100 - 15 - 15 - 7 - 7 - 3) + expect(grade).toBe('D') + }) +}) + +function expandImportsLogic(content: string, resolveMap: Record, depth = 0): number { + if (depth > 5) return 0 + let total = content.split('\n').length + const matches = content.matchAll(/^@([^\s]+)/gm) + for (const m of matches) { + const key = m[1] || '' + if (resolveMap[key]) { + total += expandImportsLogic(resolveMap[key], resolveMap, depth + 1) + } + } + return total +} + +describe('optimize: @-import expansion', () => { + it('counts only the base file when no imports', () => { + const total = expandImportsLogic('line 1\nline 2\nline 3', {}) + expect(total).toBe(3) + }) + + it('expands single @-import', () => { + const main = 'line 1\n@./imported.md\nline 3' + const imported = 'i1\ni2\ni3\ni4\ni5' + expect(expandImportsLogic(main, { './imported.md': imported })).toBe(3 + 5) + }) + + it('expands nested @-imports recursively', () => { + const main = 'a\n@b.md' + const b = 'b1\nb2\n@c.md' + const c = 'c1\nc2\nc3' + expect(expandImportsLogic(main, { 'b.md': b, 'c.md': c })).toBe(2 + 3 + 3) + }) + + it('caps recursion depth', () => { + const circular = '@x.md' + expect(expandImportsLogic(circular, { 'x.md': circular })).toBeLessThan(10) + }) +})