diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts new file mode 100644 index 0000000..6a045de --- /dev/null +++ b/src/act/optimize-apply.ts @@ -0,0 +1,125 @@ +import { createInterface } from 'node:readline/promises' +import { homedir } from 'os' +import chalk from 'chalk' +import type { DateRange, ProjectSummary } from '../types.js' +import { scanAndDetect } from '../optimize.js' +import { formatCost } from '../currency.js' +import { formatTokens } from '../format.js' +import { runAction } from './apply.js' +import { shortId } from './journal.js' +import { planFindings, type FindingPlan, type PlanContext } from './plans.js' + +export type ApplyOptions = { + yes?: boolean + dryRun?: boolean + only?: string + actionsDir?: string + ctx?: PlanContext +} + +function short(p: string): string { + const home = homedir() + return p.startsWith(home) ? '~' + p.slice(home.length) : p +} + +function changeLines(fp: FindingPlan): string[] { + return fp.plan!.changes.map(c => + c.op === 'move' ? `${short(c.path)} -> ${short(c.movedTo)}` : short(c.path), + ) +} + +export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string { + const lines: string[] = [''] + lines.push(chalk.bold(' Appliable config-class fixes:')) + appliable.forEach((fp, i) => { + const f = fp.finding + const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}` + lines.push('') + lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`) + for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`)) + for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + }) + if (manual.length > 0) { + lines.push('') + lines.push(chalk.dim(' Not auto-appliable (apply by hand):')) + for (const fp of manual) lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`)) + } + lines.push('') + return lines.join('\n') +} + +function selectPlans(answer: string, appliable: FindingPlan[]): FindingPlan[] { + const a = answer.trim().toLowerCase() + if (a === 'a' || a === 'all' || a === 'y' || a === 'yes') return appliable + if (a === '' || a === 'q' || a === 'quit' || a === 'n' || a === 'no') return [] + const picked: FindingPlan[] = [] + for (const token of a.split(/[\s,]+/)) { + const n = Number.parseInt(token, 10) + if (Number.isInteger(n) && n >= 1 && n <= appliable.length && !picked.includes(appliable[n - 1]!)) { + picked.push(appliable[n - 1]!) + } + } + return picked +} + +async function ask(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + return await rl.question(question) + } finally { + rl.close() + } +} + +export async function runOptimizeApply( + projects: ProjectSummary[], + dateRange: DateRange | undefined, + opts: ApplyOptions = {}, +): Promise { + process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + const { findings, costRate } = await scanAndDetect(projects, dateRange) + const plans = planFindings(findings, opts.ctx) + + let appliable = plans.filter(p => p.plan !== null) + const manual = plans.filter(p => p.plan === null) + + if (opts.only) { + const wanted = new Set(opts.only.split(',').map(s => s.trim()).filter(Boolean)) + appliable = appliable.filter(p => wanted.has(p.finding.id)) + } + + if (appliable.length === 0) { + console.log(chalk.dim('\n No appliable config-class fixes for this period.\n')) + return + } + + console.log(renderApplyList(appliable, manual, costRate)) + + if (opts.dryRun) { + console.log(chalk.dim(' Dry run: nothing was changed.\n')) + return + } + + let selected = appliable + if (!opts.yes) { + const answer = await ask(' Apply all / pick numbers / quit [a / 1 2 3 / q]: ') + selected = selectPlans(answer, appliable) + if (selected.length === 0) { + console.log(chalk.dim(' Nothing applied.\n')) + return + } + } + + console.log('') + for (const fp of selected) { + try { + const record = await runAction(fp.plan!, opts.actionsDir) + console.log(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } catch (e) { + console.error(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`)) + process.exitCode = 1 + } + } + console.log('') +} diff --git a/src/act/plans.ts b/src/act/plans.ts new file mode 100644 index 0000000..d6747a9 --- /dev/null +++ b/src/act/plans.ts @@ -0,0 +1,385 @@ +import { existsSync, readFileSync } from 'fs' +import { isAbsolute, join } from 'path' +import { homedir } from 'os' +import type { ActionKind, ActionPlan, PlannedChange } from './types.js' +import type { WasteFinding } from '../optimize.js' + +// Turns an optimize finding into a concrete, journaled file-mutation plan. +// Only config-class findings are appliable; everything else yields plan: null +// (shown as "manual" by the CLI). Every path is derived from an injectable +// context so tests can point the whole thing at a fixture home. + +export type PlanContext = { + homeDir?: string + cwd?: string + shell?: string +} + +export type BuiltPlan = { + plan: ActionPlan | null + // Human-facing skip reasons and parse errors, surfaced in the apply summary. + notes: string[] +} + +export type FindingPlan = BuiltPlan & { finding: WasteFinding } + +type ResolvedPaths = { + homeDir: string + cwd: string + projectMcpJson: string + projectSettings: string + projectSettingsLocal: string + userClaudeJson: string + skillsDir: string + agentsDir: string + commandsDir: string + projectClaudeMd: string + shellRc: string +} + +function resolvePaths(ctx: PlanContext): ResolvedPaths { + const homeDir = ctx.homeDir ?? homedir() + const cwd = ctx.cwd ?? process.cwd() + const shell = ctx.shell ?? process.env['SHELL'] ?? '' + return { + homeDir, + cwd, + projectMcpJson: join(cwd, '.mcp.json'), + projectSettings: join(cwd, '.claude', 'settings.json'), + projectSettingsLocal: join(cwd, '.claude', 'settings.local.json'), + userClaudeJson: join(homeDir, '.claude.json'), + skillsDir: join(homeDir, '.claude', 'skills'), + agentsDir: join(homeDir, '.claude', 'agents'), + commandsDir: join(homeDir, '.claude', 'commands'), + projectClaudeMd: join(cwd, 'CLAUDE.md'), + shellRc: join(homeDir, /zsh/.test(shell) ? '.zshrc' : '.bashrc'), + } +} + +export function planFor(finding: WasteFinding, ctx: PlanContext = {}): ActionPlan | null { + return buildPlan(finding, resolvePaths(ctx)).plan +} + +export function planFindings(findings: WasteFinding[], ctx: PlanContext = {}): FindingPlan[] { + const r = resolvePaths(ctx) + return findings.map(finding => ({ finding, ...buildPlan(finding, r) })) +} + +function buildPlan(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + switch (finding.id) { + case 'mcp-low-coverage': return buildMcpRemove(finding, r) + case 'mcp-project-scope': return buildMcpProjectScope(finding, r) + case 'unused-skills': return buildArchive(finding, r, 'skill') + case 'unused-agents': return buildArchive(finding, r, 'agent') + case 'unused-commands': return buildArchive(finding, r, 'command') + case 'bash-output-cap': return buildShellConfig(finding, r) + default: + if (finding.fix.type === 'paste' && finding.fix.destination === 'claude-md') { + return buildClaudeMdRule(finding, r) + } + return { plan: null, notes: [] } + } +} + +// --------------------------------------------------------------------------- +// MCP config editing (remove + project-scope) +// --------------------------------------------------------------------------- + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +function shortPath(p: string, homeDir: string): string { + return p.startsWith(homeDir) ? '~' + p.slice(homeDir.length) : p +} + +// Config keys are the server's original name; coverage findings carry the +// runtime-normalized form (":" -> "_"). Match either. +function findServerKey(container: Record | undefined, server: string): string | null { + if (!container) return null + for (const k of Object.keys(container)) { + if (k === server || k.replace(/:/g, '_') === server) return k + } + return null +} + +type DocState = { path: string; doc: Record; existed: boolean; dirty: boolean } + +// Reads each config file at most once, tracks parse errors, and emits one +// PlannedChange per file it actually mutated. +class ConfigDocs { + private docs = new Map() + private errors = new Map() + constructor(private homeDir: string) {} + + load(path: string): DocState | null { + if (this.docs.has(path)) return this.docs.get(path)! + if (!existsSync(path)) { + const state: DocState = { path, doc: {}, existed: false, dirty: false } + this.docs.set(path, state) + return state + } + let raw: string + try { + raw = readFileSync(path, 'utf-8') + } catch (e) { + this.errors.set(path, `could not read ${shortPath(path, this.homeDir)}: ${errMessage(e)}`) + this.docs.set(path, null) + return null + } + try { + const doc = JSON.parse(raw) as Record + const state: DocState = { path, doc, existed: true, dirty: false } + this.docs.set(path, state) + return state + } catch (e) { + this.errors.set(path, `could not parse ${shortPath(path, this.homeDir)}: ${errMessage(e)}`) + this.docs.set(path, null) + return null + } + } + + changes(): PlannedChange[] { + const out: PlannedChange[] = [] + for (const state of this.docs.values()) { + if (state && state.dirty) { + out.push({ + op: state.existed ? 'edit' : 'create', + path: state.path, + content: JSON.stringify(state.doc, null, 2) + '\n', + }) + } + } + return out + } + + errorNotes(): string[] { + return [...this.errors.values()] + } +} + +function serverContainers(state: DocState, isUserClaudeJson: boolean): Array> { + const containers: Array> = [] + const top = state.doc.mcpServers + if (top && typeof top === 'object') containers.push(top as Record) + if (isUserClaudeJson) { + const projects = state.doc.projects + if (projects && typeof projects === 'object') { + for (const entry of Object.values(projects as Record)) { + const pm = (entry as Record | null)?.['mcpServers'] + if (pm && typeof pm === 'object') containers.push(pm as Record) + } + } + } + return containers +} + +function deleteServer(state: DocState, server: string, isUserClaudeJson: boolean): boolean { + let removed = false + for (const container of serverContainers(state, isUserClaudeJson)) { + const key = findServerKey(container, server) + if (key) { + delete container[key] + state.dirty = true + removed = true + } + } + return removed +} + +function readServerValue(state: DocState, server: string, isUserClaudeJson: boolean): unknown { + for (const container of serverContainers(state, isUserClaudeJson)) { + const key = findServerKey(container, server) + if (key) return container[key] + } + return undefined +} + +function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : [] + const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson] + const docs = new ConfigDocs(r.homeDir) + const skips: string[] = [] + + for (const server of servers) { + let removed = false + for (const path of searchPaths) { + const state = docs.load(path) + if (!state) continue + if (deleteServer(state, server, path === r.userClaudeJson)) removed = true + } + if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) + } + + const changes = docs.changes() + const notes = [...docs.errorNotes(), ...skips] + if (changes.length === 0) return { plan: null, notes } + return { + plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes), + notes, + } +} + +function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + const entries = finding.apply?.kind === 'mcp-project-scope' ? finding.apply.servers : [] + const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson] + const docs = new ConfigDocs(r.homeDir) + const skips: string[] = [] + + for (const { server, keepProjects } of entries) { + const keepers = keepProjects.filter(p => isAbsolute(p)) + if (keepers.length === 0) { + skips.push(`skipped ${server}: no absolute keeper project path to scope into`) + continue + } + + let value: unknown + for (const path of searchPaths) { + const state = docs.load(path) + if (!state) continue + const found = readServerValue(state, server, path === r.userClaudeJson) + if (found !== undefined) { value = found; break } + } + if (value === undefined) { + skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) + continue + } + + const keeperMcpPaths = new Set(keepers.map(k => join(k, '.mcp.json'))) + for (const path of searchPaths) { + if (keeperMcpPaths.has(path)) continue + const state = docs.load(path) + if (!state) continue + deleteServer(state, server, path === r.userClaudeJson) + } + + for (const keeper of keepers) { + const state = docs.load(join(keeper, '.mcp.json')) + if (!state) { + skips.push(`skipped ${server} for ${keeper}: its .mcp.json could not be parsed`) + continue + } + const existing = state.doc.mcpServers + const mcpServers = existing && typeof existing === 'object' + ? existing as Record + : (state.doc.mcpServers = {}) + mcpServers[server] = value + state.dirty = true + } + } + + const changes = docs.changes() + const notes = [...docs.errorNotes(), ...skips] + if (changes.length === 0) return { plan: null, notes } + return { + plan: mcpPlan('mcp-project-scope', finding.id, `Project-scope ${entries.length === 1 ? 'an MCP server' : 'MCP servers'}`, changes), + notes, + } +} + +function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan { + return { kind, findingId, description, changes } +} + +// --------------------------------------------------------------------------- +// Archive unused skills / agents / commands +// --------------------------------------------------------------------------- + +const ARCHIVE_KIND: Record<'skill' | 'agent' | 'command', ActionKind> = { + skill: 'archive-skill', + agent: 'archive-agent', + command: 'archive-command', +} + +function withSuffix(base: string, n: number): string { + const dot = base.lastIndexOf('.') + return dot === -1 ? `${base}-${n}` : `${base.slice(0, dot)}-${n}${base.slice(dot)}` +} + +function buildArchive(finding: WasteFinding, r: ResolvedPaths, capability: 'skill' | 'agent' | 'command'): BuiltPlan { + const names = finding.apply?.kind === 'archive' ? finding.apply.names : [] + const baseDir = capability === 'skill' ? r.skillsDir : capability === 'agent' ? r.agentsDir : r.commandsDir + const isDir = capability === 'skill' + const archivedDir = join(baseDir, '.archived') + const changes: PlannedChange[] = [] + const notes: string[] = [] + const claimed = new Set() + + for (const name of names) { + const source = isDir ? join(baseDir, name) : join(baseDir, `${name}.md`) + if (!existsSync(source)) { + notes.push(`skipped ${name}: ${shortPath(source, r.homeDir)} no longer exists`) + continue + } + const destBase = isDir ? name : `${name}.md` + let dest = join(archivedDir, destBase) + let n = 2 + while (existsSync(dest) || claimed.has(dest)) { + dest = join(archivedDir, withSuffix(destBase, n)) + n++ + } + claimed.add(dest) + changes.push({ op: 'move', path: source, movedTo: dest }) + } + + if (changes.length === 0) return { plan: null, notes } + return { + plan: { + kind: ARCHIVE_KIND[capability], + findingId: finding.id, + description: `Archive ${changes.length} unused ${capability}${changes.length === 1 ? '' : 's'}`, + changes, + }, + notes, + } +} + +// --------------------------------------------------------------------------- +// Marker-block edits (CLAUDE.md rule, shell rc) +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function upsertMarkerBlock(existing: string | null, id: string, text: string, style: 'html' | 'hash'): string { + const begin = style === 'html' ? `` : `# codeburn:begin ${id}` + const end = style === 'html' ? `` : `# codeburn:end ${id}` + const block = `${begin}\n${text}\n${end}\n` + if (!existing) return block + const region = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`) + if (region.test(existing)) return existing.replace(region, block) + return existing.endsWith('\n') ? existing + block : existing + '\n' + block +} + +function buildClaudeMdRule(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.projectClaudeMd + const existing = existsSync(target) ? readFileSync(target, 'utf-8') : null + const content = upsertMarkerBlock(existing, finding.id, finding.fix.text, 'html') + return { + plan: { + kind: 'claude-md-rule', + findingId: finding.id, + description: `Add the ${finding.id} rule block to ${shortPath(target, r.homeDir)}`, + changes: [{ op: existing === null ? 'create' : 'edit', path: target, content }], + }, + notes: [], + } +} + +function buildShellConfig(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.shellRc + const existing = existsSync(target) ? readFileSync(target, 'utf-8') : null + const content = upsertMarkerBlock(existing, finding.id, finding.fix.text, 'hash') + return { + plan: { + kind: 'shell-config', + findingId: finding.id, + description: `Set the bash output cap in ${shortPath(target, r.homeDir)}`, + changes: [{ op: existing === null ? 'create' : 'edit', path: target, content }], + }, + notes: [], + } +} diff --git a/src/main.ts b/src/main.ts index e793420..ffa0300 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1328,13 +1328,28 @@ program .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--format ', 'Output format: text, json', 'text') + .option('--json', 'Output findings as JSON (alias for --format json)') + .option('--apply', 'Interactively apply config-class fixes (backed up, journaled, undoable)') + .option('--yes', 'With --apply: apply every appliable fix without prompting') + .option('--dry-run', 'With --apply: print the plan and exit without changing anything') + .option('--only ', 'With --apply: restrict to a comma-separated list of finding ids') .action(async (opts) => { - assertFormat(opts.format, ['text', 'json'], 'optimize') assertProvider(opts.provider, 'optimize') + const format = opts.json ? 'json' : opts.format + if (opts.apply && format === 'json') { + process.stderr.write('codeburn optimize: --apply cannot be combined with --json\n') + process.exit(2) + } await loadPricing() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) - await runOptimize(projects, label, range, { format: opts.format }) + if (opts.apply) { + const { runOptimizeApply } = await import('./act/optimize-apply.js') + await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only }) + return + } + assertFormat(format, ['text', 'json'], 'optimize') + await runOptimize(projects, label, range, { format }) }) program diff --git a/src/optimize.ts b/src/optimize.ts index 43925fe..ddbcd27 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -186,13 +186,44 @@ export type WasteAction = export type Trend = 'active' | 'improving' +// Stable, kebab-case identifier per detector. Used to route findings to +// appliable plans (src/act/plans.ts) and for the `--only` filter, so these +// strings must not change once shipped. +export type FindingId = + | 'read-edit-ratio' + | 'build-folder-reads' + | 'redundant-rereads' + | 'warmup-heavy' + | 'unused-mcp' + | 'mcp-low-coverage' + | 'mcp-project-scope' + | 'retry-heavy-capabilities' + | 'low-worth-sessions' + | 'context-heavy-sessions' + | 'cost-outliers' + | 'claude-md-too-long' + | 'bash-output-cap' + | 'unused-agents' + | 'unused-skills' + | 'unused-commands' + +// Machine-readable payload the apply layer needs but the human-facing `fix` +// text can't carry losslessly (full lists, per-server keeper paths). Only set +// on findings that have an appliable plan; absent otherwise. +export type FindingApply = + | { kind: 'mcp-remove'; servers: string[] } + | { kind: 'mcp-project-scope'; servers: Array<{ server: string; keepProjects: string[] }> } + | { kind: 'archive'; names: string[] } + export type WasteFinding = { + id: FindingId title: string explanation: string impact: Impact tokensSaved: number fix: WasteAction trend?: Trend + apply?: FindingApply } export type OptimizeResult = { @@ -221,6 +252,7 @@ export type OptimizeJsonReport = { costRateUSD: number } findings: Array<{ + id: FindingId title: string explanation: string severity: Impact @@ -547,6 +579,7 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste const dirsToAvoid = [...detected, ...extras].join(', ') return { + id: 'build-folder-reads', title: 'Claude is reading build/dependency folders', explanation: `Claude read into ${dirList} (${totalJunkReads} reads). These are generated or dependency directories, not your code. Tell Claude in CLAUDE.md to avoid them.`, impact: totalJunkReads > JUNK_READS_HIGH_THRESHOLD ? 'high' : totalJunkReads > JUNK_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -607,6 +640,7 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ return { + id: 'redundant-rereads', title: 'Claude is re-reading the same files', explanation: `${totalDuplicates} redundant re-reads across sessions. Top repeats: ${worst}. Each re-read loads the same content into context again.`, impact: totalDuplicates > DUPLICATE_READS_HIGH_THRESHOLD ? 'high' : totalDuplicates > DUPLICATE_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -922,6 +956,7 @@ export function detectMcpToolCoverage( : 'medium' return { + id: 'mcp-low-coverage', title: `${flagged.length} MCP server${flagged.length === 1 ? '' : 's'} with low tool coverage`, explanation: `Schema for unused tools is loaded into the system prompt every session and ` + @@ -936,6 +971,7 @@ export function detectMcpToolCoverage( : 'Remove underused servers, or trim their tools in your MCP config:', text: removeCommands.join('\n'), }, + apply: { kind: 'mcp-remove', servers: flaggedServers }, } } @@ -1164,6 +1200,7 @@ export function detectMcpProfileAdvisor( : 'medium' return { + id: 'mcp-project-scope', title: `${candidates.length} MCP server${candidates.length === 1 ? '' : 's'} should be project-scoped`, explanation: `These MCP servers look useful in a small set of projects but are loaded into other projects where they are not invoked. ` + @@ -1183,6 +1220,10 @@ export function detectMcpProfileAdvisor( }), ].join('\n'), }, + apply: { + kind: 'mcp-project-scope', + servers: candidates.map(c => ({ server: c.server, keepProjects: c.hotProjects.map(p => p.projectPath) })), + }, } } @@ -1412,6 +1453,7 @@ export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFi const verb = candidates.length === 1 ? 'correlates' : 'correlate' return { + id: 'retry-heavy-capabilities', title: `${candidates.length} ${candidates.length === 1 ? noun : pluralNoun} ${verb} with retry-heavy edits`, explanation: `Edit turns using these capabilities are retry-heavy: ${list}${extra}. This is a correlation report, not proof of causation; compare the retry-heavy turns with one-shot turns before changing MCP scope or skill instructions.`, impact, @@ -1478,6 +1520,7 @@ export function detectUnusedMcp( const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1) return { + id: 'unused-mcp', title: `${unused.length} MCP server${unused.length > 1 ? 's' : ''} configured but never used`, explanation: `Never called in this period: ${unused.join(', ')}. Each server loads ~${TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL} tokens of tool schema into every session.`, impact: unused.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium', @@ -1541,6 +1584,7 @@ export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | }).join(', ') return { + id: 'claude-md-too-long', title: `Your CLAUDE.md is too long`, explanation: `${list}. CLAUDE.md plus all @-imported files load into every API call. Trimming below ${CLAUDEMD_HEALTHY_LINES} lines saves ~${formatTokens(tokensSaved)} tokens per call.`, impact: worst.expandedLines > CLAUDEMD_HIGH_THRESHOLD_LINES ? 'high' : 'medium', @@ -1589,6 +1633,7 @@ export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { if (trend === 'resolved') return null return { + id: 'read-edit-ratio', title: 'Claude edits more than it reads', explanation: `Claude made ${reads} reads and ${edits} edits (ratio ${ratio.toFixed(1)}:1). A healthy ratio is ${HEALTHY_READ_EDIT_RATIO}+ reads per edit. Editing without reading leads to retries and wasted tokens.`, impact, @@ -1660,6 +1705,7 @@ export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSumma } return { + id: 'warmup-heavy', title: 'Session warmup is unusually large', explanation: `Median cache_creation per call is ${formatTokens(median)} tokens, about ${formatTokens(excess)} above your baseline of ${formatTokens(baseline)}.${versionNote}`, impact: excess > CACHE_EXCESS_HIGH_THRESHOLD ? 'high' : 'medium', @@ -1712,6 +1758,7 @@ export async function detectGhostAgents(calls: ToolCall[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-agents', title: `${ghosts.length} custom agent${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `Defined in ~/.claude/agents/ but never invoked in this period: ${list}. Each adds ~${TOKENS_PER_AGENT_DEF} tokens to the Task tool schema on every session.`, impact: ghosts.length >= GHOST_AGENTS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_AGENTS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1721,6 +1768,7 @@ export async function detectGhostAgents(calls: ToolCall[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1742,6 +1790,7 @@ export async function detectGhostSkills(calls: ToolCall[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-skills', title: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `In ~/.claude/skills/ but not invoked this period: ${list}. Each adds ~${TOKENS_PER_SKILL_DEF} tokens of metadata to every session.`, impact: ghosts.length >= GHOST_SKILLS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_SKILLS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1751,6 +1800,7 @@ export async function detectGhostSkills(calls: ToolCall[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1774,6 +1824,7 @@ export async function detectGhostCommands(userMessages: string[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-commands', title: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `In ~/.claude/commands/ but not referenced this period: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`, impact: ghosts.length >= GHOST_COMMANDS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1783,6 +1834,7 @@ export async function detectGhostCommands(userMessages: string[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1810,6 +1862,7 @@ export function detectBashBloat(): WasteFinding | null { const tokensSaved = Math.round(extraChars * BASH_TOKENS_PER_CHAR) return { + id: 'bash-output-cap', title: 'Shrink bash output limit', explanation: `Your bash output cap is ${(limit / 1000).toFixed(0)}K chars (${configured ? 'configured' : 'default'}). Most output fits in ${(BASH_RECOMMENDED_LIMIT / 1000).toFixed(0)}K. The extra ~${formatTokens(tokensSaved)} tokens per bash call is trailing noise.`, impact: 'medium', @@ -2002,6 +2055,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding } return { + id: 'low-worth-sessions', title: `${candidates.length} possibly low-worth expensive session${candidates.length === 1 ? '' : 's'}`, explanation: `Sessions with meaningful spend but weak delivery signals: ${list}${extra}. This is a review candidate, not proof of waste: CodeBurn flags missing edit turns, repeated retries, and sessions without git delivery commands so you can decide whether the work was worth its cost before it becomes a habit.`, impact, @@ -2115,6 +2169,7 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId } return { + id: 'context-heavy-sessions', title: `${candidates.length} context-heavy session${candidates.length === 1 ? '' : 's'}`, explanation: `Effective input/cache tokens swamp output in these sessions: ${list}${extra}. This can come from stale context carryover, inherently context-heavy work, or abandoned runs that loaded too much context; starting fresh with only the current goal and relevant files can cut repeated prompt overhead.`, impact, @@ -2185,6 +2240,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio const totalExcessCost = outliers.reduce((sum, o) => sum + Math.max(0, o.cost - o.avgCost), 0) return { + id: 'cost-outliers', title: `${outliers.length} high-cost session outlier${outliers.length === 1 ? '' : 's'}`, explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`, impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium', @@ -2566,6 +2622,7 @@ export function buildOptimizeJsonReport( costRateUSD: result.costRate, }, findings: result.findings.map(f => ({ + id: f.id, title: f.title, explanation: f.explanation, severity: f.impact, diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts new file mode 100644 index 0000000..460101e --- /dev/null +++ b/tests/optimize-apply.test.ts @@ -0,0 +1,322 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' + +import { planFor, planFindings } from '../src/act/plans.js' +import { renderApplyList } from '../src/act/optimize-apply.js' +import { runAction } from '../src/act/apply.js' +import { undoAction } from '../src/act/undo.js' +import { readRecords } from '../src/act/journal.js' +import { + detectBloatedClaudeMd, + detectDuplicateReads, + detectJunkReads, + detectLowReadEditRatio, + detectMcpToolCoverage, +} from '../src/optimize.js' +import type { + FindingApply, + FindingId, + McpServerCoverage, + ToolCall, + WasteAction, + WasteFinding, +} from '../src/optimize.js' + +const roots: string[] = [] + +type Fixture = { root: string; home: string; project: string; actionsDir: string } + +async function makeFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'codeburn-optimize-apply-')) + roots.push(root) + const home = join(root, 'home') + const project = join(root, 'project') + await mkdir(home, { recursive: true }) + await mkdir(project, { recursive: true }) + return { root, home, project, actionsDir: join(root, 'actions') } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +function makeFinding(id: FindingId, fix: WasteAction, apply?: FindingApply): WasteFinding { + return { id, title: id, explanation: '', impact: 'medium', tokensSaved: 1000, fix, ...(apply ? { apply } : {}) } +} + +const CMD_FIX: WasteAction = { type: 'command', label: '', text: '' } + +async function hashTree(dir: string): Promise { + const h = createHash('sha256') + async function walk(d: string): Promise { + const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)) + for (const entry of entries) { + const full = join(d, entry.name) + if (entry.isDirectory()) { + h.update('D:' + full + '\n') + await walk(full) + } else { + h.update('F:' + full + '\n') + h.update(await readFile(full)) + } + } + } + await walk(dir) + return h.digest('hex') +} + +describe('mcp-remove plan', () => { + it('deletes exactly the named server, leaves other keys untouched, and undo restores byte-identical', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const original = JSON.stringify({ + mcpServers: { alpha: { command: 'a' }, beta: { command: 'b', args: ['x'] } }, + numFoo: 3, + nested: { keep: true }, + }, null, 2) + '\n' + await writeFile(claudeJson, original) + await writeFile(join(fx.project, '.mcp.json'), JSON.stringify({ mcpServers: { gamma: {} } }, null, 2) + '\n') + + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['beta'] }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + expect(plan!.changes).toHaveLength(1) + expect(plan!.changes[0]!.path).toBe(claudeJson) + + const rec = await runAction(plan!, fx.actionsDir) + + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({ alpha: { command: 'a' } }) + expect(after.numFoo).toBe(3) + expect(after.nested).toEqual({ keep: true }) + // Untouched sibling config file. + expect(JSON.parse(await readFile(join(fx.project, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ gamma: {} }) + // 2-space indent + trailing newline contract. + expect(await readFile(claudeJson, 'utf-8')).toBe(JSON.stringify(after, null, 2) + '\n') + + await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeJson, 'utf-8')).toBe(original) + }) +}) + +describe('mcp-project-scope plan', () => { + it('moves the entry from the global config into the keeper project .mcp.json, creating it when missing', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const serverValue = { command: 'srv', args: ['--flag'], env: { A: '1' } } + const original = JSON.stringify({ mcpServers: { srv: serverValue }, other: 1 }, null, 2) + '\n' + await writeFile(claudeJson, original) + + const keeper = join(fx.root, 'keeper') + await mkdir(keeper, { recursive: true }) + const keeperMcp = join(keeper, '.mcp.json') + expect(existsSync(keeperMcp)).toBe(false) + + const finding = makeFinding('mcp-project-scope', { type: 'paste', destination: 'prompt', label: '', text: '' }, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [keeper] }], + }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + + const rec = await runAction(plan!, fx.actionsDir) + + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + expect(existsSync(keeperMcp)).toBe(true) + expect(JSON.parse(await readFile(keeperMcp, 'utf-8')).mcpServers).toEqual({ srv: serverValue }) + + await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeJson, 'utf-8')).toBe(original) + expect(existsSync(keeperMcp)).toBe(false) + }) +}) + +describe('unparseable config file', () => { + it('reports the parse error, skips that server, and still applies the servers it can read', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { good: { command: 'g' } } }, null, 2) + '\n') + const brokenMcp = join(fx.project, '.mcp.json') + await writeFile(brokenMcp, '{ this is not valid json,,, ') + + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['good', 'bad'] }) + const { plan, notes } = planFindings([finding], { homeDir: fx.home, cwd: fx.project })[0]! + + expect(notes.some(n => /could not parse/.test(n) && n.includes('.mcp.json'))).toBe(true) + expect(notes.some(n => n.includes('bad'))).toBe(true) + expect(plan).not.toBeNull() + expect(plan!.changes.map(c => c.path)).toEqual([claudeJson]) + + await runAction(plan!, fx.actionsDir) + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + // The broken file is left exactly as-is. + expect(await readFile(brokenMcp, 'utf-8')).toBe('{ this is not valid json,,, ') + }) +}) + +describe('archive plan', () => { + it('archives a skill dir and an agent file, round-trips undo, and suffixes a colliding name with -2', async () => { + const fx = await makeFixture() + const skillsDir = join(fx.home, '.claude', 'skills') + const agentsDir = join(fx.home, '.claude', 'agents') + await mkdir(join(skillsDir, 'foo'), { recursive: true }) + await writeFile(join(skillsDir, 'foo', 'SKILL.md'), 'skill body') + // Pre-existing archive with the same name forces the -2 suffix. + await mkdir(join(skillsDir, '.archived', 'foo'), { recursive: true }) + await writeFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'old archived') + await mkdir(agentsDir, { recursive: true }) + await writeFile(join(agentsDir, 'bar.md'), 'agent body') + + const skillFinding = makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }) + const skillPlan = planFor(skillFinding, { homeDir: fx.home, cwd: fx.project }) + expect(skillPlan!.changes[0]).toMatchObject({ + op: 'move', + path: join(skillsDir, 'foo'), + movedTo: join(skillsDir, '.archived', 'foo-2'), + }) + const skillRec = await runAction(skillPlan!, fx.actionsDir) + expect(existsSync(join(skillsDir, 'foo'))).toBe(false) + expect(await readFile(join(skillsDir, '.archived', 'foo-2', 'SKILL.md'), 'utf-8')).toBe('skill body') + // The pre-existing archive is preserved. + expect(await readFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'utf-8')).toBe('old archived') + + const agentFinding = makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['bar'] }) + const agentPlan = planFor(agentFinding, { homeDir: fx.home, cwd: fx.project }) + expect(agentPlan!.changes[0]).toMatchObject({ + op: 'move', + path: join(agentsDir, 'bar.md'), + movedTo: join(agentsDir, '.archived', 'bar.md'), + }) + const agentRec = await runAction(agentPlan!, fx.actionsDir) + expect(existsSync(join(agentsDir, 'bar.md'))).toBe(false) + + await undoAction({ id: agentRec.id }, { actionsDir: fx.actionsDir }) + await undoAction({ id: skillRec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(join(agentsDir, 'bar.md'), 'utf-8')).toBe('agent body') + expect(await readFile(join(skillsDir, 'foo', 'SKILL.md'), 'utf-8')).toBe('skill body') + expect(existsSync(join(skillsDir, '.archived', 'foo-2'))).toBe(false) + }) +}) + +describe('claude-md rule plan', () => { + it('appends a fresh marker block, replaces it in place on re-apply, and undo removes it', async () => { + const fx = await makeFixture() + const claudeMd = join(fx.project, 'CLAUDE.md') + const original = '# Project\n\nExisting rules.\n' + await writeFile(claudeMd, original) + + const first = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read before editing.' }) + const firstPlan = planFor(first, { homeDir: fx.home, cwd: fx.project }) + const firstRec = await runAction(firstPlan!, fx.actionsDir) + + let body = await readFile(claudeMd, 'utf-8') + expect(body).toContain('# Project') + expect(body).toContain('') + expect(body).toContain('Read before editing.') + expect(body).toContain('') + + // Second apply with the same id replaces the block instead of duplicating. + const second = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first, then edit.' }) + const secondPlan = planFor(second, { homeDir: fx.home, cwd: fx.project }) + const secondRec = await runAction(secondPlan!, fx.actionsDir) + + body = await readFile(claudeMd, 'utf-8') + expect(body.match(/codeburn:begin read-edit-ratio/g)).toHaveLength(1) + expect(body).toContain('Read first, then edit.') + expect(body).not.toContain('Read before editing.') + + await undoAction({ id: secondRec.id }, { actionsDir: fx.actionsDir }) + await undoAction({ id: firstRec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeMd, 'utf-8')).toBe(original) + }) +}) + +describe('shell-config plan', () => { + it('writes the bash cap inside # markers to the rc chosen from $SHELL', async () => { + const fx = await makeFixture() + const finding = makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }) + expect(plan!.changes[0]!.path).toBe(join(fx.home, '.zshrc')) + + await runAction(plan!, fx.actionsDir) + const body = await readFile(join(fx.home, '.zshrc'), 'utf-8') + expect(body).toBe('# codeburn:begin bash-output-cap\nexport BASH_MAX_OUTPUT_LENGTH=15000\n# codeburn:end bash-output-cap\n') + }) +}) + +describe('dry-run', () => { + it('leaves the fixture tree byte-identical when only planning', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { s: { command: 'c' } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'ghost', 'SKILL.md'), 'x') + await writeFile(join(fx.project, 'CLAUDE.md'), '# rules\n') + + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }), + makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }), + ] + + const before = await hashTree(fx.root) + const plans = planFindings(findings, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }) + // Exercise the exact rendering the dry-run path prints. + renderApplyList(plans.filter(p => p.plan !== null), plans.filter(p => p.plan === null), 0.000002) + const after = await hashTree(fx.root) + + expect(plans.every(p => p.plan !== null)).toBe(true) + expect(after).toBe(before) + }) +}) + +describe('finding-id regression guard', () => { + const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/ + const KNOWN: ReadonlySet = new Set([ + 'read-edit-ratio', 'build-folder-reads', 'redundant-rereads', 'warmup-heavy', + 'unused-mcp', 'mcp-low-coverage', 'mcp-project-scope', 'retry-heavy-capabilities', + 'low-worth-sessions', 'context-heavy-sessions', 'cost-outliers', 'claude-md-too-long', + 'bash-output-cap', 'unused-agents', 'unused-skills', 'unused-commands', + ]) + + it('every finding produced by a detector run carries a stable, known, non-empty id', async () => { + const fx = await makeFixture() + const bigClaudeMd = '# Rules\n' + Array.from({ length: 260 }, (_, i) => `- rule ${i}`).join('\n') + '\n' + await writeFile(join(fx.project, 'CLAUDE.md'), bigClaudeMd) + + function read(file: string, session = 's1'): ToolCall { + return { name: 'Read', input: { file_path: file }, sessionId: session, project: 'p' } + } + const calls: ToolCall[] = [ + read('/p/node_modules/a.js'), read('/p/node_modules/b.js'), read('/p/dist/c.js'), + ...Array.from({ length: 6 }, () => read('/p/src/app.ts')), + ...Array.from({ length: 10 }, (): ToolCall => ({ name: 'Edit', input: {}, sessionId: 's1', project: 'p' })), + ] + const coverage: McpServerCoverage[] = [{ + server: 'x', toolsAvailable: 20, toolsInvoked: 1, + unusedTools: Array.from({ length: 19 }, (_, i) => `mcp__x__t${i}`), + invocations: 1, loadedSessions: 3, coverageRatio: 0.05, + }] + + const findings = [ + detectLowReadEditRatio(calls), + detectJunkReads(calls), + detectDuplicateReads(calls), + detectBloatedClaudeMd(new Set([fx.project])), + detectMcpToolCoverage([], coverage), + ].filter((f): f is WasteFinding => f !== null) + + expect(findings.length).toBeGreaterThanOrEqual(5) + for (const f of findings) { + expect(f.id).toBeTruthy() + expect(f.id).toMatch(KEBAB) + expect(KNOWN.has(f.id)).toBe(true) + } + const ids = findings.map(f => f.id) + expect(new Set(ids).size).toBe(ids.length) + }) +})