diff --git a/src/act/apply.ts b/src/act/apply.ts new file mode 100644 index 0000000..7daba0c --- /dev/null +++ b/src/act/apply.ts @@ -0,0 +1,100 @@ +import { lstat, mkdir, rename, rm, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' +import type { ActionPlan, ActionRecord, FileChange } from './types.js' +import { appendRecord, defaultActionsDir, withLock } from './journal.js' +import { backupDirFor, relBackupPath, revertChange, sha256File, snapshotFile } from './backup.js' + +// The only mutation path. Order: back up every file the plan touches, apply +// the mutations, hash the results, then journal. If a mutation or the journal +// append throws, the steps already applied are rolled back (newest first) and +// nothing is journaled. +export async function runAction(plan: ActionPlan, actionsDir: string = defaultActionsDir()): Promise { + return withLock(actionsDir, async () => { + const id = randomUUID() + const at = new Date().toISOString() + const backupDir = backupDirFor(actionsDir, id) + await mkdir(backupDir, { recursive: true }) + + // One snapshot per unique path (first occurrence wins), so a path touched + // twice still reverts to its true pre-action bytes. + const snapshots = new Map() + let n = 0 + const snapshot = async (p: string): Promise => { + if (!snapshots.has(p)) { + const existed = await snapshotFile(p, join(backupDir, `${n}.bak`)) + snapshots.set(p, existed ? relBackupPath(id, n++) : null) + } + return snapshots.get(p)! + } + + const changes: FileChange[] = [] + for (const pc of plan.changes) { + changes.push({ + path: pc.path, + backup: await snapshot(pc.path), + op: pc.op, + ...(pc.op === 'move' ? { movedTo: pc.movedTo, destBackup: await snapshot(pc.movedTo) } : {}), + afterHash: '', + }) + } + + const done: number[] = [] + try { + // Stale-plan guard: plans carry full post-edit content computed at + // build time, so refuse if a target changed between preview and + // confirm. Runs before any mutation; failure needs no rollback (the + // catch below only removes the backup dir). + for (const pc of plan.changes) { + if (pc.op === 'move' || pc.expectedHash === undefined) continue + if ((await sha256File(pc.path)) !== pc.expectedHash) { + throw new Error(`${pc.path} changed since the plan was built; re-run codeburn optimize --apply`) + } + } + for (let i = 0; i < plan.changes.length; i++) { + const pc = plan.changes[i]! + if (pc.op === 'move') { + await mkdir(dirname(pc.movedTo), { recursive: true }) + try { + await rename(pc.path, pc.movedTo) + } catch (err) { + // rename cannot replace a directory destination. It is already + // snapshotted (destBackup), so clear it and retry. Other codes + // (e.g. a missing source) rethrow before any destination damage. + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EISDIR' && code !== 'ENOTDIR') throw err + await rm(pc.movedTo, { recursive: true, force: true }) + await rename(pc.path, pc.movedTo) + } + } else { + await mkdir(dirname(pc.path), { recursive: true }) + await writeFile(pc.path, pc.content) + } + done.push(i) + } + // Hash after ALL mutations so overlapping changes carry the final state. + // Directories get '' (no content hash); drift detection skips them. + for (const change of changes) { + const p = change.op === 'move' ? change.movedTo! : change.path + const st = await lstat(p).catch(() => null) + change.afterHash = st && !st.isDirectory() ? (await sha256File(p)) ?? '' : '' + } + const record: ActionRecord = { + id, + at, + kind: plan.kind, + findingId: plan.findingId ?? null, + description: plan.description, + changes, + status: 'applied', + ...(plan.baseline ? { baseline: plan.baseline } : {}), + } + await appendRecord(actionsDir, record) + return record + } catch (err) { + for (const i of done.reverse()) await revertChange(actionsDir, changes[i]!) + await rm(backupDir, { recursive: true, force: true }) + throw err + } + }) +} diff --git a/src/act/backup.ts b/src/act/backup.ts new file mode 100644 index 0000000..740ad99 --- /dev/null +++ b/src/act/backup.ts @@ -0,0 +1,78 @@ +import { copyFile, cp, lstat, mkdir, readFile, rename, rm } from 'fs/promises' +import { createHash } from 'crypto' +import { dirname, join } from 'path' +import type { FileChange } from './types.js' + +export function backupDirFor(actionsDir: string, id: string): string { + return join(actionsDir, 'backups', id) +} + +export function relBackupPath(id: string, index: number): string { + return `backups/${id}/${index}.bak` +} + +// Snapshot src (file or directory tree) to dest if it exists; return whether +// it existed so the caller can record backup: null for a create. +export async function snapshotFile(src: string, dest: string): Promise { + try { + const st = await lstat(src) + if (st.isDirectory()) await cp(src, dest, { recursive: true }) + else await copyFile(src, dest) + return true + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false + throw err + } +} + +export function sha256(buf: Buffer): string { + return createHash('sha256').update(buf).digest('hex') +} + +export async function sha256File(path: string): Promise { + try { + return sha256(await readFile(path)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } +} + +export async function pathExists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +// Reverse a single applied change. Shared by mid-apply rollback and undo. +// Non-move reverts key on backup presence, not the op label, so a create that +// overwrote an existing file and an edit of a missing file restore correctly. +export async function revertChange(actionsDir: string, change: FileChange): Promise { + const restore = async (backup: string, to: string): Promise => { + const src = join(actionsDir, backup) + await mkdir(dirname(to), { recursive: true }) + if ((await lstat(src)).isDirectory()) { + await rm(to, { recursive: true, force: true }) + await cp(src, to, { recursive: true }) + } else { + await copyFile(src, to) + } + } + if (change.op === 'move') { + if (await pathExists(change.movedTo!)) { + await rm(change.path, { recursive: true, force: true }) + await mkdir(dirname(change.path), { recursive: true }) + await rename(change.movedTo!, change.path) + if (change.destBackup) await restore(change.destBackup, change.movedTo!) + } else if (change.backup) { + // The moved file is gone; fall back to the source snapshot. + await restore(change.backup, change.path) + } + return + } + if (change.backup) await restore(change.backup, change.path) + else await rm(change.path, { recursive: true, force: true }) +} diff --git a/src/act/cli.ts b/src/act/cli.ts new file mode 100644 index 0000000..15bf2f7 --- /dev/null +++ b/src/act/cli.ts @@ -0,0 +1,85 @@ +import type { Command } from 'commander' +import { renderTable } from '../text-table.js' +import { defaultActionsDir, readRecords, shortId } from './journal.js' +import { DriftError, undoAction } from './undo.js' +import { buildActReportJson, computeActReport, renderActReport } from './report.js' + +function formatWhen(at: string): string { + return at.replace('T', ' ').slice(0, 16) +} + +export function registerActCommands(program: Command): void { + const act = program + .command('act') + .description('Review and undo changes codeburn has applied') + + act + .command('list') + .description('List applied actions, newest first') + .option('--json', 'Output the full records as JSON') + .action(async (opts: { json?: boolean }) => { + try { + const records = (await readRecords(defaultActionsDir())).reverse() + if (opts.json) { + console.log(JSON.stringify(records, null, 2)) + return + } + if (records.length === 0) { + console.log('No actions recorded yet.') + return + } + const rows = records.map(r => [shortId(r.id), formatWhen(r.at), r.description, r.status]) + console.log(renderTable( + [{ header: 'ID' }, { header: 'When' }, { header: 'Description' }, { header: 'Status' }], + rows, + )) + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + }) + + act + .command('undo [id]') + .description('Undo an action by id (8-char prefix accepted), or the most recent with --last') + .option('--last', 'Undo the most recent action') + .option('--force', 'Undo even if the target files changed since they were applied') + .action(async (id: string | undefined, opts: { last?: boolean; force?: boolean }) => { + if (!id && !opts.last) { + console.error('Specify an action id or --last.') + process.exitCode = 1 + return + } + try { + const record = await undoAction(opts.last ? { last: true } : { id: id! }, { force: opts.force }) + console.log(`Undid ${shortId(record.id)}: ${record.description}`) + } catch (err) { + if (err instanceof DriftError) { + console.error(err.message + ':') + for (const f of err.drifted) console.error(` ${f}`) + console.error('Re-run with --force to undo anyway.') + } else { + console.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } + }) + + act + .command('report') + .description('Realized vs estimated savings for applied actions older than 3 days') + .option('--json', 'Output the realized report as JSON') + .action(async (opts: { json?: boolean }) => { + try { + const report = await computeActReport() + if (opts.json) { + console.log(JSON.stringify(buildActReportJson(report), null, 2)) + return + } + console.log(renderActReport(report)) + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + }) +} diff --git a/src/act/journal.ts b/src/act/journal.ts new file mode 100644 index 0000000..1d3710b --- /dev/null +++ b/src/act/journal.ts @@ -0,0 +1,93 @@ +import { appendFile, mkdir, readFile, rm, stat, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getConfigFilePath } from '../config.js' +import type { ActionRecord } from './types.js' + +// Actions live beside config.json under the same CodeBurn home dir; reuse the +// config resolver rather than inventing a second location. +export function defaultActionsDir(): string { + return join(dirname(getConfigFilePath()), 'actions') +} + +export function journalPath(actionsDir: string): string { + return join(actionsDir, 'journal.jsonl') +} + +export function shortId(id: string): string { + return id.slice(0, 8) +} + +export async function appendRecord(actionsDir: string, record: ActionRecord): Promise { + await mkdir(actionsDir, { recursive: true }) + await appendFile(journalPath(actionsDir), JSON.stringify(record) + '\n', 'utf-8') +} + +// Append-only JSONL: a status flip is a full replacement line for the same id, +// so the last line for an id wins. Returns records in creation (first-seen) +// order. Unparseable lines are skipped so a corrupt journal never crashes. +export async function readRecords(actionsDir: string): Promise { + let raw: string + try { + raw = await readFile(journalPath(actionsDir), 'utf-8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + const order: string[] = [] + const byId = new Map() + for (const line of raw.split('\n')) { + if (!line.trim()) continue + let rec: ActionRecord + try { + rec = JSON.parse(line) as ActionRecord + } catch { + continue + } + if (!rec || typeof rec.id !== 'string') continue + if (!byId.has(rec.id)) order.push(rec.id) + byId.set(rec.id, rec) + } + return order.map(id => byId.get(id)!) +} + +const LOCK_STALE_MS = 60_000 + +function lockPath(actionsDir: string): string { + return join(actionsDir, '.lock') +} + +async function acquireLock(lock: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + // A single wx write: the lock is never observable in an empty state, so + // a freshly taken lock cannot be stolen as stale. + await writeFile(lock, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' }) + return + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + let mtimeMs: number + try { + mtimeMs = (await stat(lock)).mtimeMs + } catch (statErr) { + if ((statErr as NodeJS.ErrnoException).code !== 'ENOENT') throw statErr + continue // holder released between write and stat; retry + } + if (Date.now() - mtimeMs <= LOCK_STALE_MS) { + throw new Error('another codeburn action is in progress (lock held); retry shortly') + } + await rm(lock, { force: true }) + } + } + throw new Error('could not acquire the codeburn action lock') +} + +export async function withLock(actionsDir: string, fn: () => Promise): Promise { + await mkdir(actionsDir, { recursive: true }) + const lock = lockPath(actionsDir) + await acquireLock(lock) + try { + return await fn() + } finally { + await rm(lock, { force: true }) + } +} diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts new file mode 100644 index 0000000..5b90685 --- /dev/null +++ b/src/act/optimize-apply.ts @@ -0,0 +1,186 @@ +import { createInterface } from 'node:readline/promises' +import { homedir } from 'os' +import chalk from 'chalk' +import type { DateRange, ProjectSummary } from '../types.js' +import { scanAndDetect, type WasteFinding } 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 + // Test seams: crafted findings skip the session scan; streams default to + // the real stdio. + findings?: WasteFinding[] + costRate?: number + input?: NodeJS.ReadableStream + output?: NodeJS.WritableStream + errorOutput?: NodeJS.WritableStream +} + +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 => { + const base = c.op === 'move' ? `${short(c.path)} -> ${short(c.movedTo)}` : short(c.path) + const note = fp.pathNotes?.[c.path] + return note ? `${base} (${note})` : base + }) +} + +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`)) + for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + } + } + 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, input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise { + const rl = createInterface({ input, output }) + try { + // EOF (piped stdin) closes the interface with the question pending; + // treat it as "quit" instead of hanging or dying silently. The close + // fallback is deferred one tick so an answer that arrived together with + // EOF still wins the race. + return await new Promise(resolve => { + rl.question(question).then(resolve, () => resolve('')) + rl.once('close', () => setImmediate(() => resolve(''))) + }) + } finally { + rl.close() + } +} + +export async function runOptimizeApply( + projects: ProjectSummary[], + dateRange: DateRange | undefined, + opts: ApplyOptions = {}, +): Promise { + const output = opts.output ?? process.stdout + const errout = opts.errorOutput ?? process.stderr + const print = (line = ''): void => { output.write(line + '\n') } + + let findings = opts.findings + let costRate = opts.costRate ?? 0 + if (!findings) { + errout.write(chalk.dim(' Analyzing your sessions...\n')) + const scanned = await scanAndDetect(projects, dateRange) + findings = scanned.findings + costRate = scanned.costRate + } + const plans = planFindings(findings, opts.ctx) + + let appliable = plans.filter(p => p.plan !== null) + const manual = plans.filter(p => p.plan === null) + + const onlyIds = opts.only ? opts.only.split(',').map(s => s.trim()).filter(Boolean) : [] + if (onlyIds.length > 0) { + const valid = new Set(appliable.map(p => p.finding.id)) + const bad = onlyIds.filter(id => !valid.has(id)) + if (bad.length > 0) { + const validList = valid.size > 0 ? [...valid].join(', ') : '(none)' + errout.write(`codeburn optimize --apply: unknown or not-appliable finding id${bad.length === 1 ? '' : 's'}: ${bad.join(', ')}. Appliable ids for this run: ${validList}\n`) + process.exitCode = 2 + return + } + appliable = appliable.filter(p => onlyIds.includes(p.finding.id)) + } + + if (appliable.length === 0) { + print(chalk.dim('\n No appliable config-class fixes for this period.')) + for (const fp of manual) { + for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`)) + } + print() + return + } + + print(renderApplyList(appliable, manual, costRate)) + + if (opts.dryRun) { + print(chalk.dim(' Dry run: nothing was changed.\n')) + return + } + + let selected: FindingPlan[] + if (opts.yes) { + // CLAUDE.md rules land in the cwd's file; blanket --yes from an unrelated + // directory would write advice into the wrong project. They need the + // interactive picker or an explicit --only selection. + const explicit = new Set(onlyIds) + const skipped = appliable.filter(fp => fp.plan!.kind === 'claude-md-rule' && !explicit.has(fp.finding.id)) + selected = appliable.filter(fp => !skipped.includes(fp)) + for (const fp of skipped) { + print(chalk.yellow(` Skipped ${fp.finding.id}: CLAUDE.md edits are not applied with --yes; use the interactive picker or --only ${fp.finding.id}.`)) + } + } else { + const answer = await ask(' Apply all / pick numbers / quit [a / 1 2 3 / q]: ', opts.input ?? process.stdin, output) + selected = selectPlans(answer, appliable) + } + + if (selected.length === 0) { + print(chalk.dim(' Nothing applied.\n')) + return + } + + // Stamp a trailing-14-day before-baseline onto each plan so runAction + // persists it and `act report` can measure realized savings later. Best + // effort: a scan failure leaves the baseline absent (reported "not + // measurable"), never blocking the apply. + try { + const { captureBaselinesForPlans } = await import('./report.js') + await captureBaselinesForPlans(selected) + } catch { /* baseline is optional; apply proceeds without it */ } + + print() + for (const fp of selected) { + try { + const record = await runAction(fp.plan!, opts.actionsDir) + print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`) + print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } catch (e) { + errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n') + process.exitCode = 1 + } + } + print() +} diff --git a/src/act/plans.ts b/src/act/plans.ts new file mode 100644 index 0000000..861d4c8 --- /dev/null +++ b/src/act/plans.ts @@ -0,0 +1,446 @@ +import { existsSync, readFileSync } from 'fs' +import { isAbsolute, join } from 'path' +import { homedir } from 'os' +import type { ActionKind, ActionPlan, PlannedChange } from './types.js' +import { sha256 } from './backup.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[] + // Per-file preview annotations (path -> text), e.g. which ~/.claude.json + // project entries lose a server. + pathNotes?: Record +} + +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 'unused-mcp': 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 + // sha256 of the raw bytes the doc was parsed from (before the BOM strip); + // null when the file did not exist. Becomes the change's expectedHash so + // runAction refuses to apply over a file edited after the plan was built. + rawHash: string | null +} + +// 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, rawHash: null } + this.docs.set(path, state) + return state + } + let buf: Buffer + try { + buf = readFileSync(path) + } catch (e) { + this.errors.set(path, `could not read ${shortPath(path, this.homeDir)}: ${errMessage(e)}`) + this.docs.set(path, null) + return null + } + const rawHash = sha256(buf) + let raw = buf.toString('utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + try { + const doc = JSON.parse(raw) as Record + const state: DocState = { path, doc, existed: true, dirty: false, rawHash } + 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', + expectedHash: state.rawHash, + }) + } + } + return out + } + + errorNotes(): string[] { + return [...this.errors.values()] + } +} + +type ContainerRef = { container: Record; projectPath: string | null } + +function serverContainers(state: DocState, isUserClaudeJson: boolean): ContainerRef[] { + const containers: ContainerRef[] = [] + const top = state.doc.mcpServers + if (top && typeof top === 'object') containers.push({ container: top as Record, projectPath: null }) + if (isUserClaudeJson) { + const projects = state.doc.projects + if (projects && typeof projects === 'object') { + for (const [projectPath, entry] of Object.entries(projects as Record)) { + const pm = (entry as Record | null)?.['mcpServers'] + if (pm && typeof pm === 'object') containers.push({ container: pm as Record, projectPath }) + } + } + } + return containers +} + +// Deletes the server from the file's containers. With projectScope set, only +// the top-level container and the listed (cold) project entries are touched; +// entries under any other project path keep their copy. +function deleteServer( + state: DocState, + server: string, + isUserClaudeJson: boolean, + projectScope?: ReadonlySet, +): { removed: boolean; projectEntries: string[] } { + let removed = false + const projectEntries: string[] = [] + for (const { container, projectPath } of serverContainers(state, isUserClaudeJson)) { + if (projectScope && projectPath !== null && !projectScope.has(projectPath)) continue + const key = findServerKey(container, server) + if (!key) continue + delete container[key] + state.dirty = true + removed = true + if (projectPath !== null) projectEntries.push(projectPath) + } + return { removed, projectEntries } +} + +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 projectRemovalNote(server: string, entries: string[], homeDir: string): string { + const noun = entries.length === 1 ? 'entry' : 'entries' + return `removes ${server} from ${entries.length} project ${noun}: ${entries.map(e => shortPath(e, homeDir)).join(', ')}` +} + +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[] = [] + const pathNotes: Record = {} + const addPathNote = (path: string, note: string): void => { + pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note + } + + for (const server of servers) { + let removed = false + for (const path of searchPaths) { + const state = docs.load(path) + if (!state) continue + const res = deleteServer(state, server, path === r.userClaudeJson) + if (res.removed) removed = true + if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir)) + } + 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, + ...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}), + } +} + +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[] = [] + const pathNotes: Record = {} + const addPathNote = (path: string, note: string): void => { + pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note + } + + for (const { server, keepProjects, removeProjects } 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 + } + + // Scoped removal: only the global entry and the finding's cold projects + // lose the server. The cwd's own config files count as cold only when + // the cwd is in the cold list; a keeper or unrelated cwd keeps its copy. + const coldSet = new Set(removeProjects) + const keeperMcpPaths = new Set(keepers.map(k => join(k, '.mcp.json'))) + for (const path of searchPaths) { + if (keeperMcpPaths.has(path)) continue + const isUser = path === r.userClaudeJson + if (!isUser && !coldSet.has(r.cwd)) continue + const state = docs.load(path) + if (!state) continue + const res = deleteServer(state, server, isUser, isUser ? coldSet : undefined) + if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir)) + } + + 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, + ...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}), + } +} + +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 markerChange(target: string, id: string, text: string, style: 'html' | 'hash'): PlannedChange { + const buf = existsSync(target) ? readFileSync(target) : null + const existing = buf === null ? null : buf.toString('utf-8') + return { + op: buf === null ? 'create' : 'edit', + path: target, + content: upsertMarkerBlock(existing, id, text, style), + expectedHash: buf === null ? null : sha256(buf), + } +} + +function buildClaudeMdRule(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.projectClaudeMd + return { + plan: { + kind: 'claude-md-rule', + findingId: finding.id, + description: `Add the ${finding.id} rule block to ${shortPath(target, r.homeDir)}`, + changes: [markerChange(target, finding.id, finding.fix.text, 'html')], + }, + notes: [], + } +} + +function buildShellConfig(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.shellRc + return { + plan: { + kind: 'shell-config', + findingId: finding.id, + description: `Set the bash output cap in ${shortPath(target, r.homeDir)}`, + changes: [markerChange(target, finding.id, finding.fix.text, 'hash')], + }, + notes: [], + } +} diff --git a/src/act/report.ts b/src/act/report.ts new file mode 100644 index 0000000..af173d8 --- /dev/null +++ b/src/act/report.ts @@ -0,0 +1,590 @@ +import { existsSync } from 'fs' +import type { DateRange, ProjectSummary, SessionSummary } from '../types.js' +import type { ActionBaseline, ActionKind, ActionRecord } from './types.js' +import type { FindingPlan } from './plans.js' +import { + AVG_TOKENS_PER_READ, + HEALTHY_READ_EDIT_RATIO, + TOKENS_PER_MCP_TOOL, + TOOLS_PER_MCP_SERVER, + TOKENS_PER_SKILL_DEF, + TOKENS_PER_AGENT_DEF, + TOKENS_PER_COMMAND_DEF, + READ_TOOL_NAMES, + EDIT_TOOL_NAMES, + aggregateMcpCoverage, + computeInputCostRate, + type McpServerCoverage, + type WasteFinding, +} from '../optimize.js' +import { parseAllSessions } from '../parser.js' +import { computeYield, type YieldSummary } from '../yield.js' +import { defaultActionsDir, readRecords } from './journal.js' +import { renderTable } from '../text-table.js' +import { formatTokens } from '../format.js' +import { formatCost } from '../currency.js' + +const DAY_MS = 24 * 60 * 60 * 1000 +const WINDOW_CAP_DAYS = 30 +const BASELINE_WINDOW_DAYS = 14 +const REPORT_MIN_AGE_DAYS = 3 +const MIN_POST_WINDOW_SESSIONS = 20 +const VOLUME_SHIFT_FACTOR = 2 + +// Encode the epic's honest-accounting rules where they are seen: estimates are +// window-scaled so both columns share a scale, each kind measures only its own +// metric, guard is correlation, and realized figures are rounded down. +const HONEST_FOOTER = + 'Estimates are scaled to the measured window for comparability; the at-apply estimate is kept in --json. ' + + 'MCP and archive realized figures are derived from per-session baselines times session counts, not independently measured. ' + + 'Each fix measures only its own metric; effects are never attributed across signals. ' + + 'Guard rows are correlation, not attribution. Realized numbers are rounded down.' + +const MCP_KINDS = new Set(['mcp-remove', 'mcp-project-scope']) +const ARCHIVE_DEF_TOKENS: Partial> = { + 'archive-skill': TOKENS_PER_SKILL_DEF, + 'archive-agent': TOKENS_PER_AGENT_DEF, + 'archive-command': TOKENS_PER_COMMAND_DEF, +} + +export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable' + +export type ActReportRow = { + id: string + appliedAt: string + date: string + kind: ActionKind + description: string + // The detector's estimate persisted at apply time, unmodified. + estimatedAtApply: number + // The estimate re-expressed over the same post-apply window as realized so + // the two table columns are comparable; falls back to estimatedAtApply for + // kinds with no window scaling. + estimatedForWindow: number + realizedTokens: number + status: RealizedStatus + confidence: 'low' | 'normal' + note: string + // guard-install only: yield split then vs now, labeled correlation. + correlation?: { + abandonedPctThen: number + abandonedPctNow: number + avgSessionCostThenUSD: number + avgSessionCostNowUSD: number + } +} + +export type ActReport = { + generatedAt: string + windowCapDays: number + costRate: number + rows: ActReportRow[] + totalRealizedTokens: number + totalRealizedCostUSD: number + measuredCount: number + activeCount: number + observedDays: number + // Journal lines that parsed as JSON but are not usable records (missing or + // unparseable `at`, missing status); skipped and surfaced, never a throw. + malformedRecords: number + // findingId -> earliest apply date of an active applied action; drives the + // optimize "(previously applied ..., re-flagged)" title suffix. + appliedByFinding: Record +} + +export type ActReportOptions = { + actionsDir?: string + now?: Date + cwd?: string + loadProjects?: (range: DateRange) => Promise + computeYield?: (range: DateRange) => Promise +} + +// --------------------------------------------------------------------------- +// Shared measurement helpers +// --------------------------------------------------------------------------- + +function ageDays(iso: string, now: Date): number { + return (now.getTime() - new Date(iso).getTime()) / DAY_MS +} + +function allSessions(projects: ProjectSummary[]): SessionSummary[] { + return projects.flatMap(p => p.sessions) +} + +function sessionsInWindow(projects: ProjectSummary[], start: Date, end: Date): SessionSummary[] { + const out: SessionSummary[] = [] + for (const p of projects) { + for (const s of p.sessions) { + if (!s.firstTimestamp) continue + const t = new Date(s.firstTimestamp).getTime() + if (t >= start.getTime() && t <= end.getTime()) out.push(s) + } + } + return out +} + +function countToolCalls(sessions: SessionSummary[], names: ReadonlySet): number { + let n = 0 + for (const s of sessions) { + for (const [tool, data] of Object.entries(s.toolBreakdown)) { + if (names.has(tool)) n += data.calls + } + } + return n +} + +function countBashCalls(sessions: SessionSummary[]): number { + let n = 0 + for (const s of sessions) { + for (const data of Object.values(s.bashBreakdown)) n += data.calls + } + return n +} + +function sessionLoadsAny(s: SessionSummary, servers: string[]): boolean { + for (const fqn of s.mcpInventory ?? []) { + const seg = fqn.split('__')[1] + if (seg && servers.includes(seg)) return true + } + for (const server of Object.keys(s.mcpBreakdown)) { + if (servers.includes(server)) return true + } + return false +} + +function countSessionsLoading(projects: ProjectSummary[], servers: string[]): number { + return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length +} + +// A kind whose realized effect is a token saving (everything except guard, +// which is a dollars/yield correlation, and out-of-scope kinds). +function isTokenKind(kind: ActionKind): boolean { + return kind !== 'guard-install' && kind !== 'guard-uninstall' && kind !== 'model-default' +} + +function confidenceFor(afterSessions: number, baseline: ActionBaseline, afterStart: Date, now: Date): 'low' | 'normal' { + if (afterSessions < MIN_POST_WINDOW_SESSIONS) return 'low' + if (baseline.sessions > 0 && baseline.windowDays > 0) { + const afterDays = Math.max((now.getTime() - afterStart.getTime()) / DAY_MS, 1) + const shift = (afterSessions / afterDays) / (baseline.sessions / baseline.windowDays) + if (shift > VOLUME_SHIFT_FACTOR || shift < 1 / VOLUME_SHIFT_FACTOR) return 'low' + } + return 'normal' +} + +// --------------------------------------------------------------------------- +// Per-kind realized deltas +// --------------------------------------------------------------------------- + +function mcpRow( + base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const servers = Object.keys(baseline.metrics) + const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0) + if (servers.length === 0 || perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' } + if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' } + // Window-scaled estimate: what the fix would save if every window session + // benefited. Realized differs from it only through still-loading sessions + // (and the revert check), so the pair is derived from session counts, not + // independently measured. + const estimatedForWindow = Math.floor(perSessionTokens * sessions.length) + const stillLoading = sessions.filter(s => sessionLoadsAny(s, servers)).length + const confidence = confidenceFor(sessions.length, baseline, afterStart, now) + if (rec.kind === 'mcp-remove' && stillLoading > 0) { + return { + ...base, + estimatedForWindow, + status: 'reverted', + confidence, + note: `reverted by user: ${servers.join(', ')} loaded again in ${stillLoading} post-apply session${stillLoading === 1 ? '' : 's'}`, + } + } + const savedSessions = Math.max(0, sessions.length - stillLoading) + return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence } +} + +function archiveRow( + base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0) + if (perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' } + if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' } + const estimatedForWindow = Math.floor(perSessionTokens * sessions.length) + const confidence = confidenceFor(sessions.length, baseline, afterStart, now) + const restored = rec.changes.some(c => c.op === 'move' && existsSync(c.path)) + if (restored) { + return { ...base, estimatedForWindow, status: 'reverted', confidence, note: 'reverted by user: an archived item was moved back into place' } + } + // Estimate and realized are the same product by construction; the measured + // signal here is the session count and the revert check, not the multiply. + return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * sessions.length), confidence } +} + +function readEditRow( + base: ActReportRow, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const editsNow = countToolCalls(sessions, EDIT_TOOL_NAMES) + const readsNow = countToolCalls(sessions, READ_TOOL_NAMES) + const editsThen = baseline.metrics['edits'] ?? 0 + const readsThen = baseline.metrics['reads'] ?? 0 + if (editsThen <= 0 || editsNow <= 0) return { ...base, note: 'not measurable: not enough edit activity to compare' } + const ratioThen = readsThen / editsThen + const ratioNow = readsNow / editsNow + // Detector estimate math: reads short of HEALTHY_READ_EDIT_RATIO per edit are + // the retry-prone deficit. Credit only the reduction in that deficit, scaled + // by current edits; a worsened ratio claims nothing. + const deficitThen = Math.max(HEALTHY_READ_EDIT_RATIO - ratioThen, 0) + const deficitNow = Math.max(HEALTHY_READ_EDIT_RATIO - ratioNow, 0) + const realized = Math.floor(Math.max(0, deficitThen - deficitNow) * editsNow * AVG_TOKENS_PER_READ) + // Same edits denominator as realized, so realized never exceeds it. + const estimatedForWindow = Math.floor(deficitThen * editsNow * AVG_TOKENS_PER_READ) + return { + ...base, + estimatedForWindow, + status: 'measured', + realizedTokens: realized, + confidence: confidenceFor(sessions.length, baseline, afterStart, now), + note: `read:edit ${ratioThen.toFixed(1)}:1 -> ${ratioNow.toFixed(1)}:1`, + } +} + +async function guardRow( + base: ActReportRow, afterStart: Date, now: Date, + baseline: ActionBaseline, opts: ActReportOptions, +): Promise { + const abandonedThen = baseline.metrics['abandonedPct'] + const avgThen = baseline.metrics['avgSessionCostUSD'] + if (abandonedThen === undefined || avgThen === undefined) { + return { ...base, note: 'not measurable: no yield baseline captured at install time' } + } + const yieldFn = opts.computeYield ?? ((range: DateRange) => computeYield(range, opts.cwd ?? process.cwd())) + let summary: YieldSummary + try { + summary = await yieldFn({ start: afterStart, end: now }) + } catch { + return { ...base, note: 'not measurable: yield could not be computed for the post-apply window' } + } + const abandonedNow = summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0 + const avgNow = summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0 + return { + ...base, + status: 'measured', + confidence: summary.total.sessions < MIN_POST_WINDOW_SESSIONS ? 'low' : 'normal', + note: 'correlation, not attribution', + correlation: { + abandonedPctThen: abandonedThen, + abandonedPctNow: abandonedNow, + avgSessionCostThenUSD: avgThen, + avgSessionCostNowUSD: avgNow, + }, + } +} + +async function computeRow(rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date, opts: ActReportOptions): Promise { + const estimatedAtApply = rec.baseline?.estimatedTokens ?? 0 + const base: ActReportRow = { + id: rec.id, + appliedAt: rec.at, + date: rec.at.slice(0, 10), + kind: rec.kind, + description: rec.description, + estimatedAtApply, + estimatedForWindow: estimatedAtApply, + realizedTokens: 0, + status: 'not-measurable', + confidence: 'normal', + note: '', + } + const baseline = rec.baseline + if (!baseline) return { ...base, note: 'not measurable: no baseline captured at apply time' } + + if (MCP_KINDS.has(rec.kind)) return mcpRow(base, rec, sessions, baseline, afterStart, now) + if (rec.kind in ARCHIVE_DEF_TOKENS) return archiveRow(base, rec, sessions, baseline, afterStart, now) + if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now) + if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' } + if (rec.kind === 'guard-install') return guardRow(base, afterStart, now, baseline, opts) + return { ...base, note: 'not measurable: kind is not tracked by act report' } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +// A journal line can be any JSON; only records with a parseable `at` date and +// a string status can be dated and filtered. Anything else is skipped and +// counted, so a corrupt journal can never crash `act report` or optimize. +function isSaneRecord(r: ActionRecord): boolean { + return typeof r.at === 'string' && typeof r.status === 'string' && !Number.isNaN(new Date(r.at).getTime()) +} + +export async function computeActReport(opts: ActReportOptions = {}): Promise { + const now = opts.now ?? new Date() + const rawRecords = await readRecords(opts.actionsDir ?? defaultActionsDir()) + const records = rawRecords.filter(isSaneRecord) + const malformedRecords = rawRecords.length - records.length + const active = records.filter(r => r.status === 'applied') + + const appliedByFinding: Record = {} + for (const r of active) { + if (!r.findingId) continue + const date = r.at.slice(0, 10) + const prev = appliedByFinding[r.findingId] + if (!prev || date < prev) appliedByFinding[r.findingId] = date + } + + const empty: ActReport = { + generatedAt: now.toISOString(), + windowCapDays: WINDOW_CAP_DAYS, + costRate: 0, + rows: [], + totalRealizedTokens: 0, + totalRealizedCostUSD: 0, + measuredCount: 0, + activeCount: active.length, + observedDays: 0, + malformedRecords, + appliedByFinding, + } + + const eligible = active.filter(r => ageDays(r.at, now) > REPORT_MIN_AGE_DAYS) + if (eligible.length === 0) return empty + + const windowStart = new Date(now.getTime() - WINDOW_CAP_DAYS * DAY_MS) + const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude')) + const projects = await loadProjects({ start: windowStart, end: now }) + const costRate = computeInputCostRate(projects) + + const rows: ActReportRow[] = [] + for (const rec of eligible) { + const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime())) + rows.push(await computeRow(rec, sessionsInWindow(projects, afterStart, now), afterStart, now, opts)) + } + + const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind)) + const totalRealizedTokens = measuredRows.reduce((s, r) => s + r.realizedTokens, 0) + const observedDays = Math.min( + WINDOW_CAP_DAYS, + measuredRows.reduce((mx, r) => Math.max(mx, Math.ceil(ageDays(r.appliedAt, now))), 0), + ) + + return { + generatedAt: now.toISOString(), + windowCapDays: WINDOW_CAP_DAYS, + costRate, + rows, + totalRealizedTokens, + totalRealizedCostUSD: totalRealizedTokens * costRate, + measuredCount: measuredRows.length, + activeCount: active.length, + observedDays, + malformedRecords, + appliedByFinding, + } +} + +export function buildOptimizeAppliedHeader(report: ActReport): string | null { + // Under-claim: only normal-confidence measured rows feed the optimize line. + // Low-confidence rows stay visible in `act report` but never in the header. + const confident = report.rows.filter(r => r.status === 'measured' && isTokenKind(r.kind) && r.confidence === 'normal') + if (confident.length === 0) return null + const tokens = confident.reduce((s, r) => s + r.realizedTokens, 0) + const generated = new Date(report.generatedAt) + const days = Math.min( + report.windowCapDays, + confident.reduce((mx, r) => Math.max(mx, Math.ceil(ageDays(r.appliedAt, generated))), 0), + ) + const cost = report.costRate > 0 ? ` (~${formatCost(tokens * report.costRate)})` : '' + return `Applied fixes: ${report.activeCount} active, realized ~${formatTokens(tokens)} tokens${cost} over ${days} day${days === 1 ? '' : 's'}. Details: codeburn act report` +} + +function realizedCell(r: ActReportRow): string { + if (r.status === 'reverted') return 'reverted' + if (r.status === 'not-measurable') return 'not measurable' + if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)` + return formatTokens(r.realizedTokens) +} + +function malformedNote(n: number): string { + return `${n} malformed record${n === 1 ? '' : 's'} skipped` +} + +export function renderActReport(report: ActReport): string { + if (report.rows.length === 0) { + const lines = ['', ' No applied actions to measure yet.'] + if (report.activeCount > 0) { + lines.push(` ${report.activeCount} action${report.activeCount === 1 ? '' : 's'} applied; measurement starts after ${REPORT_MIN_AGE_DAYS} days.`) + } else { + lines.push(' Apply fixes with codeburn optimize --apply, then check back after a few days.') + } + if (report.malformedRecords > 0) lines.push(` ${malformedNote(report.malformedRecords)}.`) + lines.push('') + return lines.join('\n') + } + + const rows = report.rows.map(r => [ + r.date, + r.description, + r.estimatedForWindow > 0 ? formatTokens(r.estimatedForWindow) : '-', + realizedCell(r), + r.status === 'measured' && isTokenKind(r.kind) ? r.confidence : '-', + ]) + const totalCost = report.costRate > 0 ? ` (~${formatCost(report.totalRealizedCostUSD)})` : '' + rows.push(['', 'Total realized', '', `${formatTokens(report.totalRealizedTokens)}${totalCost}`, '']) + + const table = renderTable( + [{ header: 'Applied' }, { header: 'Action' }, { header: 'Estimated', right: true }, { header: 'Realized', right: true }, { header: 'Confidence' }], + rows, + { boldRows: new Set([rows.length - 1]) }, + ) + + const details: string[] = [] + for (const r of report.rows) { + if (r.status === 'measured' && isTokenKind(r.kind)) continue + if (r.note) details.push(` ${r.date} ${r.kind}: ${r.note}`) + if (r.correlation) { + details.push(` avg session cost ${formatCost(r.correlation.avgSessionCostThenUSD)} -> ${formatCost(r.correlation.avgSessionCostNowUSD)}`) + } + } + if (report.malformedRecords > 0) details.push(` ${malformedNote(report.malformedRecords)}`) + + return ['', table, ...(details.length > 0 ? ['', ...details] : []), '', ' ' + HONEST_FOOTER, ''].join('\n') +} + +export function buildActReportJson(report: ActReport): unknown { + return { + generatedAt: report.generatedAt, + windowCapDays: report.windowCapDays, + malformedRecords: report.malformedRecords, + actions: report.rows.map(r => { + const tokenMeasured = r.status === 'measured' && isTokenKind(r.kind) + return { + id: r.id, + date: r.date, + kind: r.kind, + description: r.description, + estimatedAtApply: r.estimatedAtApply, + estimatedForWindow: r.estimatedForWindow, + realizedTokens: tokenMeasured ? r.realizedTokens : null, + status: r.status, + confidence: tokenMeasured ? r.confidence : null, + note: r.note, + ...(r.correlation ? { correlation: r.correlation } : {}), + } + }), + totals: { + realizedTokens: report.totalRealizedTokens, + realizedCostUSD: report.totalRealizedCostUSD, + measuredActions: report.measuredCount, + activeActions: report.activeCount, + observedDays: report.observedDays, + }, + footer: HONEST_FOOTER, + } +} + +// --------------------------------------------------------------------------- +// Baseline capture (apply time) +// --------------------------------------------------------------------------- + +type CaptureCtx = { + projects: ProjectSummary[] + coverage: McpServerCoverage[] + windowDays: number + now: Date +} + +function mcpServersFromApply(finding: WasteFinding): string[] { + if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers + if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server) + return [] +} + +function needsConfigBaseline(kind: ActionKind): boolean { + return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config' +} + +export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined { + const common = { + windowDays: ctx.windowDays, + capturedAt: ctx.now.toISOString(), + estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)), + } + + if (MCP_KINDS.has(kind)) { + const servers = mcpServersFromApply(finding) + if (servers.length === 0) return undefined + const covByServer = new Map(ctx.coverage.map(c => [c.server, c])) + const metrics: Record = {} + for (const server of servers) { + const cov = covByServer.get(server) + const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER + metrics[server] = tools * TOKENS_PER_MCP_TOOL + } + return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics } + } + + const defTokens = ARCHIVE_DEF_TOKENS[kind] + if (defTokens !== undefined) { + const names = finding.apply?.kind === 'archive' ? finding.apply.names : [] + if (names.length === 0) return undefined + const metrics: Record = {} + for (const name of names) metrics[name] = defTokens + return { ...common, sessions: allSessions(ctx.projects).length, metrics } + } + + const sessions = allSessions(ctx.projects) + if (kind === 'claude-md-rule') { + return { ...common, sessions: sessions.length, metrics: { reads: countToolCalls(sessions, READ_TOOL_NAMES), edits: countToolCalls(sessions, EDIT_TOOL_NAMES) } } + } + if (kind === 'shell-config') { + return { ...common, sessions: sessions.length, metrics: { calls: countBashCalls(sessions) } } + } + return undefined +} + +// Scan the trailing 14 days once and stamp a baseline onto every appliable +// plan that carries one, so runAction persists it for `act report` to diff. +export async function captureBaselinesForPlans( + plans: FindingPlan[], + opts: { now?: Date; loadProjects?: (range: DateRange) => Promise } = {}, +): Promise { + const applicable = plans.filter(fp => fp.plan && needsConfigBaseline(fp.plan.kind)) + if (applicable.length === 0) return + const now = opts.now ?? new Date() + const start = new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS) + const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude')) + const projects = await loadProjects({ start, end: now }) + const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now } + for (const fp of applicable) { + const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx) + if (baseline) fp.plan!.baseline = baseline + } +} + +export async function captureGuardBaseline( + opts: { now?: Date; cwd?: string; computeYield?: (range: DateRange) => Promise } = {}, +): Promise { + const now = opts.now ?? new Date() + const range = { start: new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS), end: now } + const yieldFn = opts.computeYield ?? ((r: DateRange) => computeYield(r, opts.cwd ?? process.cwd())) + let summary: YieldSummary + try { + summary = await yieldFn(range) + } catch { + return undefined + } + return { + windowDays: BASELINE_WINDOW_DAYS, + capturedAt: now.toISOString(), + estimatedTokens: 0, + sessions: summary.total.sessions, + metrics: { + abandonedPct: summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0, + avgSessionCostUSD: summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0, + }, + } +} diff --git a/src/act/types.ts b/src/act/types.ts new file mode 100644 index 0000000..1f0baf6 --- /dev/null +++ b/src/act/types.ts @@ -0,0 +1,66 @@ +export type ActionKind = + | 'mcp-remove' | 'mcp-project-scope' + | 'archive-skill' | 'archive-agent' | 'archive-command' + | 'claude-md-rule' | 'shell-config' + | 'guard-install' | 'guard-uninstall' + | 'model-default' + +export type FileChange = { + path: string // absolute path modified + backup: string | null // backups//.bak relative to the actions dir, null if the file did not exist before + op: 'edit' | 'create' | 'move' + movedTo?: string // for op: 'move' (archives) + destBackup?: string | null // move ops: snapshot of a file that already existed at movedTo + afterHash: string // sha256 of the post-apply bytes, checked for drift on undo +} + +// Before/after measurement captured when an action is applied, diffed against +// the post-apply window by `act report`. `metrics` holds the kind-specific +// numbers: +// mcp-remove / mcp-project-scope: server name -> schema tokens per session +// archive-skill|agent|command: item name -> definition tokens per session +// claude-md-rule (read/edit rule): { reads, edits } +// shell-config (bash cap): { calls } +// guard-install: { abandonedPct, avgSessionCostUSD } +// estimatedTokens is the finding's estimate at apply time (0 for guard, which +// is a correlation signal, not a token estimate). sessions is the affected-scope +// session count over the window, kept out of `metrics` so it can never collide +// with a server literally named "sessions"; it feeds only the volume-shift +// confidence check. +export type ActionBaseline = { + windowDays: number + capturedAt: string + estimatedTokens: number + sessions: number + metrics: Record +} + +export type ActionRecord = { + id: string // crypto.randomUUID() + at: string // ISO timestamp + kind: ActionKind + findingId: string | null + description: string // one human sentence, shown in `act list` + changes: FileChange[] + status: 'applied' | 'undone' + undoneAt?: string + baseline?: ActionBaseline +} + +// expectedHash: sha256 of the raw on-disk bytes the plan's content was +// computed from (null when the plan expects the file to be absent). runAction +// refuses to apply when the target no longer matches, so a file edited +// between preview and confirm is never silently clobbered with stale +// content. undefined skips the check. +export type PlannedChange = + | { op: 'edit'; path: string; content: string | Buffer; expectedHash?: string | null } + | { op: 'create'; path: string; content: string | Buffer; expectedHash?: string | null } + | { op: 'move'; path: string; movedTo: string } + +export type ActionPlan = { + kind: ActionKind + description: string + findingId?: string | null + changes: PlannedChange[] + baseline?: ActionBaseline +} diff --git a/src/act/undo.ts b/src/act/undo.ts new file mode 100644 index 0000000..570ca1e --- /dev/null +++ b/src/act/undo.ts @@ -0,0 +1,77 @@ +import type { ActionRecord, FileChange } from './types.js' +import { appendRecord, defaultActionsDir, readRecords, shortId, withLock } from './journal.js' +import { pathExists, revertChange, sha256File } from './backup.js' + +export class DriftError extends Error { + constructor(public record: ActionRecord, public drifted: string[]) { + super(`Refusing to undo ${shortId(record.id)}: ${drifted.length} file(s) changed since they were applied`) + this.name = 'DriftError' + } +} + +export function findRecord(records: ActionRecord[], idOrPrefix: string): ActionRecord | undefined { + const exact = records.find(r => r.id === idOrPrefix) + if (exact) return exact + const matches = records.filter(r => r.id.startsWith(idOrPrefix)) + if (matches.length > 1) { + throw new Error(`"${idOrPrefix}" matches ${matches.length} actions; use more characters.`) + } + return matches[0] +} + +// A move leaves the bytes at movedTo, so that is the path to hash for drift. +function currentPath(change: FileChange): string { + return change.op === 'move' ? change.movedTo! : change.path +} + +async function driftedFiles(record: ActionRecord): Promise { + const drifted: string[] = [] + for (const change of record.changes) { + // Undo of a move renames back onto the original path; refuse if something + // now occupies it rather than silently overwriting. + if (change.op === 'move' && await pathExists(change.path)) { + drifted.push(`${change.path} (occupied, undo would overwrite it)`) + } + if (change.afterHash === '') continue // no content hash (directories) + const p = currentPath(change) + try { + if ((await sha256File(p)) !== change.afterHash) drifted.push(p) + } catch (err) { + drifted.push(`${p} (unreadable: ${(err as NodeJS.ErrnoException).code ?? 'unknown'})`) + } + } + return drifted +} + +export type UndoSelector = { id: string } | { last: true } + +export async function undoAction( + selector: UndoSelector, + opts: { actionsDir?: string; force?: boolean } = {}, +): Promise { + const actionsDir = opts.actionsDir ?? defaultActionsDir() + return withLock(actionsDir, async () => { + const records = await readRecords(actionsDir) + let record: ActionRecord | undefined + if ('last' in selector) { + record = records.filter(r => r.status === 'applied').at(-1) + if (!record) throw new Error('Nothing to undo.') + } else { + record = findRecord(records, selector.id) + if (!record) throw new Error(`No action matches "${selector.id}".`) + } + if (record.status === 'undone') { + throw new Error(`Action ${shortId(record.id)} is already undone.`) + } + if (!opts.force) { + const drifted = await driftedFiles(record) + if (drifted.length > 0) throw new DriftError(record, drifted) + } + for (let i = record.changes.length - 1; i >= 0; i--) { + await revertChange(actionsDir, record.changes[i]!) + } + const undone: ActionRecord = { ...record, status: 'undone', undoneAt: new Date().toISOString() } + await appendRecord(actionsDir, undone) + return undone + }) +} diff --git a/src/guard/cli.ts b/src/guard/cli.ts new file mode 100644 index 0000000..4ac49ff --- /dev/null +++ b/src/guard/cli.ts @@ -0,0 +1,229 @@ +import type { Command } from 'commander' +import { readdir, stat, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import chalk from 'chalk' + +type Scope = { global?: boolean; project?: string } + +function readStdin(): Promise { + return new Promise(resolve => { + const stdin = process.stdin + if (stdin.isTTY) { resolve(''); return } + let data = '' + stdin.setEncoding('utf-8') + stdin.on('data', c => { data += c }) + stdin.on('end', () => resolve(data)) + stdin.on('error', () => resolve('')) + }) +} + +function usd(n: number | null): string { + return n === null ? 'off' : `$${n}` +} + +async function refreshFlags(): Promise { + const { parseAllSessions } = await import('../parser.js') + const { buildFlags, writeFlags } = await import('./flags.js') + const projects = await parseAllSessions() + const flags = await buildFlags(projects) + await writeFlags(flags) + return flags.projects.length +} + +async function doInstall(scope: Scope, statusline: boolean): Promise { + const { settingsPathFor, buildInstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + const { readGuardConfig, writeGuardConfig, guardConfigPath, DEFAULT_GUARD_CONFIG } = await import('./store.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildInstall(path, { statusline }) + for (const note of built.notes) console.log(chalk.yellow(` ! ${note}`)) + if (built.plan) { + // Capture a trailing-14-day yield baseline so `act report` can correlate + // the guard install against later yield. Best effort; a failure just + // leaves the guard row "not measurable". + try { + const { captureGuardBaseline } = await import('../act/report.js') + const baseline = await captureGuardBaseline({ cwd: process.cwd() }) + if (baseline) built.plan.baseline = baseline + } catch { /* baseline is optional */ } + const record = await runAction(built.plan) + console.log(` Installed ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } else { + console.log(chalk.dim(` ${path}: nothing to change.`)) + } + + if (!existsSync(guardConfigPath())) { + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, updatedAt: new Date().toISOString() }) + const c = await readGuardConfig() + console.log(chalk.dim(` Wrote guard.json (soft ${usd(c.softUSD)}, hard ${usd(c.hardUSD)}, checkpoint ${usd(c.checkpointUSD)}).`)) + } + + try { + const flagged = await refreshFlags() + console.log(chalk.dim(` Flagged ${flagged} project${flagged === 1 ? '' : 's'} for session openers.`)) + } catch (e) { + console.log(chalk.yellow(` ! could not compute session-opener flags: ${e instanceof Error ? e.message : String(e)}`)) + } +} + +async function doUninstall(scope: Scope): Promise { + const { settingsPathFor, buildUninstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildUninstall(path) + for (const note of built.notes) console.log(chalk.dim(` ${note}`)) + if (built.plan) { + const record = await runAction(built.plan) + console.log(` Uninstalled ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } +} + +async function doStatus(): Promise { + const { readGuardConfig } = await import('./store.js') + const { inspectInstall, settingsPathFor } = await import('./settings.js') + const { readFlags, flagsAgeMs } = await import('./flags.js') + + const config = await readGuardConfig() + console.log(chalk.bold('\n codeburn guard')) + console.log(` soft cap: ${usd(config.softUSD)}`) + console.log(` hard cap: ${usd(config.hardUSD)}`) + console.log(` checkpoint: ${usd(config.checkpointUSD)}`) + console.log(` openers: ${config.openerEnabled ? 'on' : 'off'}`) + + const locations = [ + { label: 'global', path: settingsPathFor({ global: true }) }, + { label: 'project', path: settingsPathFor({ cwd: process.cwd() }) }, + ] + const found = locations + .map(l => ({ ...l, info: inspectInstall(l.path) })) + .filter(l => l.info.hooks.length > 0 || l.info.statusline) + if (found.length === 0) { + console.log(' installed: nowhere (run: codeburn guard install)') + } else { + for (const l of found) { + const bits = [...new Set(l.info.hooks)].join(', ') + console.log(` installed: ${l.label} ${l.path} [${bits}${l.info.statusline ? ', statusline' : ''}]`) + } + } + + const flags = await readFlags() + if (!flags) { + console.log(' flags: none (run: codeburn guard refresh)') + } else { + const ageDays = flagsAgeMs(flags) / 86_400_000 + console.log(` flags: ${flags.projects.length} project${flags.projects.length === 1 ? '' : 's'}, ${ageDays.toFixed(1)}d old`) + } + console.log() +} + +async function doAllow(sessionId: string | undefined): Promise { + const { sessionsDir } = await import('./store.js') + const { writeAllow } = await import('./usage.js') + let id = sessionId + if (!id) { + const dir = sessionsDir() + const names = (await readdir(dir).catch(() => [])).filter(f => f.endsWith('.json')) + let newest = { at: -1, id: '' } + for (const name of names) { + const st = await stat(join(dir, name)).catch(() => null) + if (!st) continue + if (st.mtimeMs > newest.at) { + try { + const cache = JSON.parse(await readFile(join(dir, name), 'utf-8')) as { sessionId?: string } + if (cache.sessionId) newest = { at: st.mtimeMs, id: cache.sessionId } + } catch { /* skip unreadable cache */ } + } + } + id = newest.id + } + if (!id) { + console.error(' No active guard session found. Pass the session id: codeburn guard allow .') + process.exitCode = 1 + return + } + await writeAllow(id) + console.log(` Lifted the guard hard cap for session ${id} (this session only).`) +} + +export function registerGuardCommands(program: Command): void { + const guard = program + .command('guard') + .description('Opt-in, removable session-time hooks for Claude Code (budget caps, openers, yield checkpoint)') + + guard + .command('install') + .description('Install the guard hooks into Claude Code settings (default: this project)') + .option('--global', 'Install into ~/.claude/settings.json instead of the project') + .option('--project ', 'Install into /.claude/settings.json') + .option('--statusline', 'Also configure the guard statusline (skipped if one already exists)') + .action(async (opts: { global?: boolean; project?: string; statusline?: boolean }) => { + try { + await doInstall({ global: opts.global, project: opts.project }, !!opts.statusline) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('uninstall') + .description('Remove the guard hooks, leaving any user hooks untouched') + .option('--global', 'Uninstall from ~/.claude/settings.json') + .option('--project ', 'Uninstall from /.claude/settings.json') + .action(async (opts: { global?: boolean; project?: string }) => { + try { + await doUninstall({ global: opts.global, project: opts.project }) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('status') + .description('Show resolved guard config, install locations, and the flag list') + .action(async () => { await doStatus() }) + + guard + .command('refresh') + .description('Recompute the per-project session-opener flag list from optimize signals') + .action(async () => { + try { + const n = await refreshFlags() + console.log(` Flagged ${n} project${n === 1 ? '' : 's'} for session openers.`) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('allow [sessionId]') + .description('Lift the hard budget cap for the current (or given) session') + .action(async (sessionId: string | undefined) => { await doAllow(sessionId) }) + + guard + .command('hook ') + .description('Internal: Claude Code invokes this with the hook payload on stdin') + .action(async (event: string) => { + const { runGuardHook } = await import('./hooks.js') + const out = await runGuardHook(event, await readStdin()) + if (out) process.stdout.write(out) + }) + + guard + .command('statusline') + .description('Internal: Claude Code statusline command; prints one line') + .action(async () => { + const { runGuardStatusline } = await import('./hooks.js') + const out = await runGuardStatusline(await readStdin()) + if (out) process.stdout.write(out + '\n') + }) +} diff --git a/src/guard/flags.ts b/src/guard/flags.ts new file mode 100644 index 0000000..0037045 --- /dev/null +++ b/src/guard/flags.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { sep } from 'path' +import type { ProjectSummary } from '../types.js' +import { flagsPath, guardDir } from './store.js' + +// Per-project flag list computed at install / `guard refresh` time and read on +// SessionStart. The resolved opener text is stored here (not a code path into +// optimize) so the hot SessionStart handler never imports the analyzer. +export type ProjectFlag = { path: string; openers: string[] } +export type GuardFlags = { generatedAt: string; projects: ProjectFlag[] } + +export const FLAG_STALE_MS = 7 * 24 * 60 * 60 * 1000 + +// optimize is loaded lazily so importing this module (for readFlags/matchFlag, +// which the SessionStart hook needs) does not pull the analyzer. +export async function buildFlags(projects: ProjectSummary[]): Promise { + const { findLowWorthCandidates, findContextBloatCandidates, LOW_WORTH_OPENER, CONTEXT_HEAVY_OPENER } = + await import('../optimize.js') + const lowWorth = new Set(findLowWorthCandidates(projects).map(c => c.project)) + const contextHeavy = new Set(findContextBloatCandidates(projects).map(c => c.project)) + const flags: ProjectFlag[] = [] + for (const project of projects) { + const openers: string[] = [] + if (lowWorth.has(project.project)) openers.push(LOW_WORTH_OPENER) + if (contextHeavy.has(project.project)) openers.push(CONTEXT_HEAVY_OPENER) + if (openers.length > 0) flags.push({ path: project.projectPath, openers }) + } + return { generatedAt: new Date().toISOString(), projects: flags } +} + +export async function readFlags(base?: string): Promise { + let raw: string + try { + raw = await readFile(flagsPath(base), 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(raw) as GuardFlags + if (typeof parsed?.generatedAt !== 'string' || !Array.isArray(parsed.projects)) return null + return parsed + } catch { + return null + } +} + +export async function writeFlags(flags: GuardFlags, base?: string): Promise { + await mkdir(guardDir(base), { recursive: true }) + await writeFile(flagsPath(base), JSON.stringify(flags, null, 2) + '\n', 'utf-8') +} + +export function flagsAgeMs(flags: GuardFlags): number { + const t = Date.parse(flags.generatedAt) + return Number.isNaN(t) ? Number.POSITIVE_INFINITY : Date.now() - t +} + +function norm(p: string): string { + return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p +} + +// Openers for the flagged project the cwd sits in (exact match or a subdir of +// it); most-specific project wins. Empty when the cwd is not flagged. +export function matchFlag(flags: GuardFlags, cwd: string): string[] { + const target = norm(cwd) + let best: ProjectFlag | null = null + for (const flag of flags.projects) { + const base = norm(flag.path) + if (target === base || target.startsWith(base + sep)) { + if (!best || base.length > norm(best.path).length) best = flag + } + } + return best ? best.openers : [] +} diff --git a/src/guard/hooks.ts b/src/guard/hooks.ts new file mode 100644 index 0000000..767b666 --- /dev/null +++ b/src/guard/hooks.ts @@ -0,0 +1,167 @@ +// =========================================================================== +// Claude Code hook protocol, verified against the live docs on 2026-07-03 +// (https://docs.anthropic.com/en/docs/claude-code/hooks -> 301 -> +// https://code.claude.com/docs/en/hooks, and .../statusline). The spec's field +// names were NOT trusted; these are the doc's: +// +// Event names (exact casing): PreToolUse, SessionStart, Stop. +// stdin JSON (snake_case) common to every event: session_id, transcript_path, +// cwd, hook_event_name, permission_mode. PreToolUse adds tool_name + +// tool_input; SessionStart adds source (startup|resume|clear|compact). +// statusLine stdin differs: session_id, transcript_path, workspace.current_dir, +// cost.total_cost_usd (plain text out; each stdout line renders as its own +// status row, so we emit exactly one). +// Exit/stdout contract: exit 0 -> stdout parsed as JSON output; exit 2 -> +// blocking, stderr fed to the model. We ALWAYS exit 0 and encode any decision +// as JSON, so an internal error is indistinguishable from "no opinion" +// (fail-open). Empty stdout = no effect. +// PreToolUse block: { hookSpecificOutput: { hookEventName: "PreToolUse", +// permissionDecision: "deny", permissionDecisionReason } } (permissionDecision +// is allow|deny|ask|defer). Non-blocking user note anywhere: top-level +// systemMessage. +// SessionStart context: { hookSpecificOutput: { hookEventName: "SessionStart", +// additionalContext } }; SessionStart cannot block. +// Stop: may block via top-level { decision: "block", reason } or exit 2; we +// never do; a non-blocking nudge uses systemMessage (additionalContext on Stop +// would force the conversation to continue, which we do not want). +// settings.json shape: { hooks: { : [ { matcher?, hooks: [ { type: +// "command", command } ] } ] } }. Stop takes no matcher; SessionStart matcher +// is startup|resume|clear|compact; an omitted matcher matches all. Statusline +// is a top-level statusLine: { type: "command", command }. +// =========================================================================== +import { readGuardConfig } from './store.js' +import { computeSessionUsage, isAllowed, readCache, writeCache } from './usage.js' +import { FLAG_STALE_MS, flagsAgeMs, matchFlag, readFlags } from './flags.js' + +export type HookOpts = { base?: string } + +function str(obj: unknown, key: string): string | undefined { + if (obj && typeof obj === 'object') { + const v = (obj as Record)[key] + if (typeof v === 'string') return v + } + return undefined +} + +function usd(n: number): string { + return `$${n.toFixed(2)}` +} + +async function handlePreToolUse(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if (config.hardUSD !== null && cache.costUSD >= config.hardUSD && !(await isAllowed(sessionId, opts.base))) { + // A block is a stronger signal than the soft nag; once blocked, don't also + // fire a soft warning on the next (e.g. post-`allow`) tool call. + cache.softWarned = true + output = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + `Session cost passed ${usd(config.hardUSD)} (codeburn guard). Run 'codeburn guard allow' to lift the cap for this session, or raise hardUSD in guard.json.`, + }, + }) + } else if (config.softUSD !== null && cache.costUSD >= config.softUSD && !cache.softWarned) { + cache.softWarned = true + output = JSON.stringify({ + systemMessage: `codeburn guard: this session is ${usd(cache.costUSD)} (soft cap ${usd(config.softUSD)}).`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +async function handleSessionStart(input: unknown, opts: HookOpts): Promise { + const cwd = str(input, 'cwd') + if (!cwd) return '' + const config = await readGuardConfig(opts.base) + if (!config.openerEnabled) return '' + const flags = await readFlags(opts.base) + if (!flags || flagsAgeMs(flags) > FLAG_STALE_MS) return '' + const openers = matchFlag(flags, cwd) + if (openers.length === 0) return '' + return JSON.stringify({ + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: openers.join('\n\n') }, + }) +} + +async function handleStop(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if ( + config.checkpointUSD !== null + && cache.costUSD > config.checkpointUSD + && !cache.sawEdit + && !cache.sawGitCommit + && !cache.stopNotified + ) { + cache.stopNotified = true + output = JSON.stringify({ + systemMessage: + `This session is ${usd(cache.costUSD)} with no edits or commits yet. If exploring is the goal, fine; otherwise consider a fresh session with a named deliverable.`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +// The fail-open boundary: parse stdin, dispatch, and turn ANY error, malformed +// payload, or unknown event into exit-0-with-empty-output. A broken guard must +// never block a session. +export async function runGuardHook(event: string, raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + switch (event.toLowerCase()) { + case 'pretooluse': return await handlePreToolUse(input, opts) + case 'sessionstart': return await handleSessionStart(input, opts) + case 'stop': return await handleStop(input, opts) + default: return '' + } + } catch { + return '' + } +} + +// statusLine is a separate command, not a hook event. One line: guard's session +// cost and how stale the incremental cache is versus a 5-minute turn TTL. +export const STATUSLINE_TTL_MS = 5 * 60 * 1000 + +export async function runGuardStatusline(raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + await writeCache(cache, opts.base) + return `codeburn guard ${usd(cache.costUSD)} · ${freshness(cache.lastTurnAt)}` + } catch { + return '' + } +} + +function freshness(lastTurnAt: string | null): string { + if (!lastTurnAt) return 'no turns yet' + const age = Date.now() - Date.parse(lastTurnAt) + if (Number.isNaN(age) || age < 0) return 'fresh' + const label = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m` + return age > STATUSLINE_TTL_MS ? `idle ${label}` : label +} diff --git a/src/guard/settings.ts b/src/guard/settings.ts new file mode 100644 index 0000000..843c2df --- /dev/null +++ b/src/guard/settings.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { sha256 } from '../act/backup.js' +import type { ActionPlan, PlannedChange } from '../act/types.js' + +// The hook entries `guard install` writes and `guard uninstall` removes. Every +// command carries the same recognizable prefix so uninstall can find exactly +// ours by substring even if the user later moved or reindented the file. +export const GUARD_HOOK_PREFIX = 'codeburn guard hook' +export const GUARD_STATUSLINE_COMMAND = 'codeburn guard statusline' + +const INSTALL_HOOKS: { event: string; matcher?: string; arg: string }[] = [ + { event: 'PreToolUse', arg: 'pretooluse' }, + { event: 'SessionStart', matcher: 'startup', arg: 'sessionstart' }, + { event: 'Stop', arg: 'stop' }, +] + +function hookCommand(arg: string): string { + return `${GUARD_HOOK_PREFIX} ${arg}` +} + +export function settingsPathFor(scope: { global?: boolean; project?: string; cwd?: string }): string { + const dir = scope.global ? homedir() : (scope.project ?? scope.cwd ?? process.cwd()) + return join(dir, '.claude', 'settings.json') +} + +type Loaded = { doc: Record; existed: boolean; rawHash: string | null } + +function load(path: string): Loaded { + if (!existsSync(path)) return { doc: {}, existed: false, rawHash: null } + const buf = readFileSync(path) + const rawHash = sha256(buf) + let raw = buf.toString('utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + let doc: unknown + try { + doc = JSON.parse(raw) + } catch (e) { + throw new Error(`could not parse ${path}: ${e instanceof Error ? e.message : String(e)}`) + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error(`${path} is not a JSON object`) + } + return { doc: doc as Record, existed: true, rawHash } +} + +type HookEntry = { type?: string; command?: string; [k: string]: unknown } +type MatcherGroup = { matcher?: string; hooks?: HookEntry[]; [k: string]: unknown } + +function asGroups(value: unknown): MatcherGroup[] { + return Array.isArray(value) ? (value as MatcherGroup[]) : [] +} + +function groupHasOurCommand(group: MatcherGroup, command: string): boolean { + return Array.isArray(group.hooks) && group.hooks.some(h => h?.command === command) +} + +export type SettingsBuild = { + plan: ActionPlan | null + path: string + existed: boolean + notes: string[] +} + +function change(path: string, existed: boolean, rawHash: string | null, doc: Record): PlannedChange { + return { + op: existed ? 'edit' : 'create', + path, + content: JSON.stringify(doc, null, 2) + '\n', + expectedHash: rawHash, + } +} + +export function buildInstall(path: string, opts: { statusline?: boolean } = {}): SettingsBuild { + const { doc, existed, rawHash } = load(path) + const notes: string[] = [] + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : {} + let added = false + + for (const { event, matcher, arg } of INSTALL_HOOKS) { + const command = hookCommand(arg) + const groups = asGroups(hooks[event]) + if (groups.some(g => groupHasOurCommand(g, command))) continue + groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] }) + hooks[event] = groups + added = true + } + if (added || Object.keys(hooks).length > 0) doc.hooks = hooks + + if (opts.statusline) { + const existing = doc.statusLine + if (existing && typeof existing === 'object' && (existing as HookEntry).command !== GUARD_STATUSLINE_COMMAND) { + notes.push('a statusline is already configured; left it untouched (remove it first to use the guard statusline)') + } else if ((existing as HookEntry | undefined)?.command === GUARD_STATUSLINE_COMMAND) { + // already ours + } else { + doc.statusLine = { type: 'command', command: GUARD_STATUSLINE_COMMAND } + added = true + } + } + + if (!added) { + notes.push('guard hooks already present; nothing to install') + return { plan: null, path, existed, notes } + } + return { + plan: { + kind: 'guard-install', + findingId: null, + description: `Install codeburn guard hooks into ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes, + } +} + +export function buildUninstall(path: string): SettingsBuild { + if (!existsSync(path)) { + return { plan: null, path, existed: false, notes: ['no settings file at that location; nothing to uninstall'] } + } + const { doc, existed, rawHash } = load(path) + let removed = false + + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : null + if (hooks) { + for (const event of Object.keys(hooks)) { + const groups = asGroups(hooks[event]) + const kept: MatcherGroup[] = [] + for (const group of groups) { + if (!Array.isArray(group.hooks)) { kept.push(group); continue } + const keptHooks = group.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) + if (keptHooks.length !== group.hooks.length) removed = true + if (keptHooks.length === 0) continue // drop a group that was only ours + kept.push(keptHooks.length === group.hooks.length ? group : { ...group, hooks: keptHooks }) + } + if (kept.length === 0) delete hooks[event] + else hooks[event] = kept + } + if (Object.keys(hooks).length === 0) delete doc.hooks + } + + const statusLine = doc.statusLine as HookEntry | undefined + if (statusLine && typeof statusLine.command === 'string' && statusLine.command.includes(GUARD_STATUSLINE_COMMAND)) { + delete doc.statusLine + removed = true + } + + if (!removed) { + return { plan: null, path, existed, notes: ['no codeburn guard hooks found in that settings file'] } + } + return { + plan: { + kind: 'guard-uninstall', + findingId: null, + description: `Remove codeburn guard hooks from ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes: [], + } +} + +// Report whether a settings file currently carries our hooks / statusline, for +// `guard status`. Never throws: a missing or malformed file reads as absent. +export function inspectInstall(path: string): { path: string; hooks: string[]; statusline: boolean } { + const out = { path, hooks: [] as string[], statusline: false } + if (!existsSync(path)) return out + let doc: Record + try { + let raw = readFileSync(path, 'utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return out + doc = parsed as Record + } catch { + return out + } + const hooks = doc.hooks + if (hooks && typeof hooks === 'object') { + for (const [event, value] of Object.entries(hooks as Record)) { + for (const group of asGroups(value)) { + if (Array.isArray(group.hooks) && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) { + out.hooks.push(event) + } + } + } + } + const sl = doc.statusLine as HookEntry | undefined + out.statusline = !!(sl && typeof sl.command === 'string' && sl.command.includes(GUARD_STATUSLINE_COMMAND)) + return out +} diff --git a/src/guard/store.ts b/src/guard/store.ts new file mode 100644 index 0000000..c9e30e6 --- /dev/null +++ b/src/guard/store.ts @@ -0,0 +1,92 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getConfigFilePath } from '../config.js' + +// Guard state lives beside config.json under the CodeBurn home dir (in practice +// ~/.config/codeburn): guard.json for thresholds, a guard/ subdir for the +// per-session incremental caches, the flag list, and per-session allow markers. +// Every path derives from an injectable base so tests point the whole thing at +// a fixture dir and the real config dir is never touched. +export function guardBase(base?: string): string { + return base ?? dirname(getConfigFilePath()) +} + +export function guardConfigPath(base?: string): string { + return join(guardBase(base), 'guard.json') +} + +export function guardDir(base?: string): string { + return join(guardBase(base), 'guard') +} + +export function flagsPath(base?: string): string { + return join(guardDir(base), 'flags.json') +} + +// Per-session state sits one level below the shared flags.json so a session id +// can never collide with it (e.g. a session literally named "flags"). +export function sessionsDir(base?: string): string { + return join(guardDir(base), 'sessions') +} + +export function sessionCachePath(sessionId: string, base?: string): string { + return join(sessionsDir(base), `${sanitizeId(sessionId)}.json`) +} + +export function allowPath(sessionId: string, base?: string): string { + return join(sessionsDir(base), `${sanitizeId(sessionId)}.allow`) +} + +// Session ids come from the hook payload; keep them to a filesystem-safe set so +// a malformed id can never escape the guard dir. +function sanitizeId(id: string): string { + return id.replace(/[^A-Za-z0-9._-]/g, '_') +} + +export type GuardConfig = { + softUSD: number | null + hardUSD: number | null + checkpointUSD: number | null + openerEnabled: boolean + updatedAt: string +} + +export const DEFAULT_GUARD_CONFIG: GuardConfig = { + softUSD: 5, + hardUSD: 15, + checkpointUSD: 3, + openerEnabled: true, + updatedAt: '', +} + +function coerceThreshold(v: unknown, fallback: number | null): number | null { + if (v === null) return null + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback +} + +export async function readGuardConfig(base?: string): Promise { + let raw: string + try { + raw = await readFile(guardConfigPath(base), 'utf-8') + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + let parsed: Partial + try { + parsed = JSON.parse(raw) as Partial + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + return { + softUSD: coerceThreshold(parsed.softUSD, DEFAULT_GUARD_CONFIG.softUSD), + hardUSD: coerceThreshold(parsed.hardUSD, DEFAULT_GUARD_CONFIG.hardUSD), + checkpointUSD: coerceThreshold(parsed.checkpointUSD, DEFAULT_GUARD_CONFIG.checkpointUSD), + openerEnabled: parsed.openerEnabled !== false, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', + } +} + +export async function writeGuardConfig(config: GuardConfig, base?: string): Promise { + await mkdir(guardBase(base), { recursive: true }) + await writeFile(guardConfigPath(base), JSON.stringify(config, null, 2) + '\n', 'utf-8') +} diff --git a/src/guard/usage.ts b/src/guard/usage.ts new file mode 100644 index 0000000..eaafdc7 --- /dev/null +++ b/src/guard/usage.ts @@ -0,0 +1,157 @@ +import { mkdir, readFile, stat, writeFile } from 'fs/promises' +import { readSessionLines } from '../fs-utils.js' +import { parseApiCall, parseJsonlLine } from '../parser.js' +import { EDIT_TOOLS } from '../classifier.js' +import { allowPath, sessionCachePath, sessionsDir } from './store.js' + +// Per-session running totals. The transcript is append-only, so each invocation +// streams only the bytes after `byteOffset` (the offset of the last complete +// line parsed) and folds them into the totals; a cold parse of a multi-hundred- +// MB transcript on every tool call is what this avoids. +// +// Claude Code rewrites each assistant message several times as it streams, and +// every copy carries the full final usage. The shipped parser dedupes those +// copies last-wins (dedupeStreamingMessageIds); a plain sum here measured real +// sessions at ~3x their true cost. `perMessage` maps message id -> that id's +// current cost contribution, and each id-carrying line REPLACES its previous +// contribution instead of adding. This also self-heals the trailing-line case: +// a complete final line without its newline is folded but byteOffset stops +// before it, so the next invocation re-reads it as a replace, not a double add. +export type GuardCache = { + version: number + sessionId: string + byteOffset: number + costUSD: number + perMessage: Record + sawEdit: boolean + sawGitCommit: boolean + lastTurnAt: string | null + updatedAt: string + softWarned: boolean + stopNotified: boolean +} + +// v2: per-message-id replace fold (perMessage map, sawEdit boolean) and the +// guard/sessions/ cache location. v1 caches are ignored and cold-reparse once. +export const GUARD_CACHE_VERSION = 2 + +// `commit` must be the git subcommand: `git`, optionally flag tokens (long +// flags, or a short flag with an optional separate value like `-c k=v`), then +// `commit` as the next word, anchored at a command boundary: string/line start +// (multi-line Bash calls separate commands with newlines) or ; & |. The gaps +// inside the command never cross a newline, so "git diff\ncommit msg" is two +// commands, not a commit. "git log --grep commit" and "git diff && echo +// commit" don't match either. +const GIT_COMMIT = /(?:^|[;&|])[^\S\n]*git(?:[^\S\n]+(?:--\S+|-\w+(?:[^\S\n]+\S+)?))*[^\S\n]+commit(?![-\w])/m + +export function emptyCache(sessionId: string): GuardCache { + return { + version: GUARD_CACHE_VERSION, + sessionId, + byteOffset: 0, + costUSD: 0, + perMessage: {}, + sawEdit: false, + sawGitCommit: false, + lastTurnAt: null, + updatedAt: '', + softWarned: false, + stopNotified: false, + } +} + +export async function readCache(sessionId: string, base?: string): Promise { + let raw: string + try { + raw = await readFile(sessionCachePath(sessionId, base), 'utf-8') + } catch { + return emptyCache(sessionId) + } + try { + const parsed = JSON.parse(raw) as Partial + if ( + parsed.version !== GUARD_CACHE_VERSION + || typeof parsed.byteOffset !== 'number' + || !parsed.perMessage || typeof parsed.perMessage !== 'object' + ) { + return emptyCache(sessionId) + } + return { ...emptyCache(sessionId), ...parsed, sessionId } + } catch { + return emptyCache(sessionId) + } +} + +export async function writeCache(cache: GuardCache, base?: string): Promise { + await mkdir(sessionsDir(base), { recursive: true }) + await writeFile(sessionCachePath(cache.sessionId, base), JSON.stringify(cache), 'utf-8') +} + +// Fold the transcript tail into the totals. Reuses the streaming line reader +// (startByteOffset + a lastCompleteLineOffset tracker) and the shared per-call +// cost/pricing path (parseApiCall -> calculateCost), so the guard never +// reimplements cost math. `resumedFrom` is the offset the parse restarted at, +// which the test asserts to prove only the tail was read. +export async function computeSessionUsage( + prev: GuardCache, + transcriptPath: string, +): Promise<{ cache: GuardCache; resumedFrom: number }> { + let size: number + try { + size = (await stat(transcriptPath)).size + } catch { + return { cache: prev, resumedFrom: prev.byteOffset } + } + + // A shorter file than we last read means the transcript was rotated or + // truncated; start over from a clean total rather than trusting a stale + // offset into different bytes. + const cache = size < prev.byteOffset + ? { ...emptyCache(prev.sessionId), softWarned: prev.softWarned, stopNotified: prev.stopNotified } + : { ...prev, perMessage: { ...prev.perMessage } } + const resumedFrom = cache.byteOffset + + const tracker = { lastCompleteLineOffset: resumedFrom } + for await (const line of readSessionLines(transcriptPath, undefined, { + startByteOffset: resumedFrom, + byteOffsetTracker: tracker, + largeLineAsBuffer: true, + })) { + const entry = parseJsonlLine(line) + if (!entry) continue + const call = parseApiCall(entry) + if (!call) continue + // Last-wins per message id, matching the shipped dedupeStreamingMessageIds. + // Lines without an id (rare, and never streamed in copies) just add. + const msgId = (entry.message as { id?: string } | undefined)?.id + if (msgId) { + cache.costUSD += call.costUSD - (cache.perMessage[msgId] ?? 0) + cache.perMessage[msgId] = call.costUSD + } else { + cache.costUSD += call.costUSD + } + for (const tc of call.toolSequence?.flat() ?? []) { + if (!cache.sawEdit && EDIT_TOOLS.has(tc.tool)) cache.sawEdit = true + if (!cache.sawGitCommit && tc.command && GIT_COMMIT.test(tc.command)) cache.sawGitCommit = true + } + if (call.timestamp) cache.lastTurnAt = call.timestamp + } + + cache.byteOffset = tracker.lastCompleteLineOffset + cache.updatedAt = new Date().toISOString() + return { cache, resumedFrom } +} + +export async function isAllowed(sessionId: string, base?: string): Promise { + try { + await stat(allowPath(sessionId, base)) + return true + } catch { + return false + } +} + +export async function writeAllow(sessionId: string, base?: string): Promise { + await mkdir(sessionsDir(base), { recursive: true }) + await writeFile(allowPath(sessionId, base), '', 'utf-8') +} diff --git a/src/main.ts b/src/main.ts index 10b4f63..a5378fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,8 @@ import { loadRemotes, saveRemotes } from './sharing/store.js' import type { UsageQuery } from './sharing/share-server.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize } from './optimize.js' +import { registerActCommands } from './act/cli.js' +import { registerGuardCommands } from './guard/cli.js' import { runContextCommand } from './context-tree.js' import { renderCompare } from './compare.js' import { @@ -1327,13 +1329,45 @@ 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') + if (format === 'text') { + // Surface realized savings from applied actions. Best effort: optimize + // must never fail because of journal contents, so any error just drops + // the header. computeActReport returns fast without scanning when the + // journal has no eligible applied actions, so users who never opted in + // see identical output. + let appliedHeader: string | undefined + let previouslyApplied: Record | undefined + try { + const { computeActReport, buildOptimizeAppliedHeader } = await import('./act/report.js') + const applied = await computeActReport() + appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined + previouslyApplied = applied.appliedByFinding + } catch { /* the header is optional; never block the findings */ } + await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied }) + } else { + await runOptimize(projects, label, range, { format }) + } }) program @@ -1538,4 +1572,7 @@ program await startStdioServer(version) }) +registerActCommands(program) +registerGuardCommands(program) + program.parse() diff --git a/src/optimize.ts b/src/optimize.ts index 43925fe..8c59e0e 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -26,12 +26,12 @@ const RED = '#F55B5B' // Token estimation constants // ============================================================================ -const AVG_TOKENS_PER_READ = 600 -const TOKENS_PER_MCP_TOOL = 400 -const TOOLS_PER_MCP_SERVER = 5 -const TOKENS_PER_AGENT_DEF = 80 -const TOKENS_PER_SKILL_DEF = 80 -const TOKENS_PER_COMMAND_DEF = 60 +export const AVG_TOKENS_PER_READ = 600 +export const TOKENS_PER_MCP_TOOL = 400 +export const TOOLS_PER_MCP_SERVER = 5 +export const TOKENS_PER_AGENT_DEF = 80 +export const TOKENS_PER_SKILL_DEF = 80 +export const TOKENS_PER_COMMAND_DEF = 60 const CLAUDEMD_TOKENS_PER_LINE = 13 const BASH_TOKENS_PER_CHAR = 0.25 @@ -48,7 +48,7 @@ const MIN_DUPLICATE_READS_TO_FLAG = 5 const DUPLICATE_READS_HIGH_THRESHOLD = 30 const DUPLICATE_READS_MEDIUM_THRESHOLD = 10 const MIN_EDITS_FOR_RATIO = 10 -const HEALTHY_READ_EDIT_RATIO = 4 +export const HEALTHY_READ_EDIT_RATIO = 4 const LOW_RATIO_HIGH_THRESHOLD = 2 const LOW_RATIO_MEDIUM_THRESHOLD = 3 const MIN_API_CALLS_FOR_CACHE = 10 @@ -186,13 +186,46 @@ 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[] } + // keepProjects gain the entry; removeProjects (the cold projects) are the + // only per-project containers a scoped removal may touch. + | { kind: 'mcp-project-scope'; servers: Array<{ server: string; keepProjects: string[]; removeProjects: 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 +254,7 @@ export type OptimizeJsonReport = { costRateUSD: number } findings: Array<{ + id: FindingId title: string explanation: string severity: Impact @@ -547,6 +581,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 +642,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 +958,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 +973,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 +1202,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 +1222,14 @@ export function detectMcpProfileAdvisor( }), ].join('\n'), }, + apply: { + kind: 'mcp-project-scope', + servers: candidates.map(c => ({ + server: c.server, + keepProjects: c.hotProjects.map(p => p.projectPath), + removeProjects: c.coldProjects.map(p => p.projectPath), + })), + }, } } @@ -1412,6 +1459,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,10 +1526,12 @@ 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', tokensSaved, + apply: { kind: 'mcp-remove', servers: unused }, fix: { type: 'command', label: `Remove unused server${unused.length > 1 ? 's' : ''}:`, @@ -1541,6 +1591,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', @@ -1554,8 +1605,8 @@ export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | } } -const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) -const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit']) +export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) +export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { let reads = 0 @@ -1589,6 +1640,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 +1712,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 +1765,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 +1775,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 +1797,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 +1807,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 +1831,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 +1841,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 +1869,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', @@ -1917,6 +1977,12 @@ function estimateLowWorthRecoverableTokens( return Math.round(tokens * fraction) } +// Session-opener texts, the single source of truth shared by the optimize +// findings below and by `codeburn guard` (SessionStart hook). Kept as +// constants so the two surfaces can never drift. +export const LOW_WORTH_OPENER = 'Before continuing, name the deliverable in one sentence (PR title, file changed, command output you expect). Stop and check with me if (a) you spend more than 10 minutes without an edit, or (b) the same approach fails twice. Do not retry past two attempts on any single fix.' +export const CONTEXT_HEAVY_OPENER = 'Start fresh before continuing. Use only the current goal, the relevant files, the failing command/output, and the constraints below. Restate the working context in under 10 bullets before editing.' + export type LowWorthCandidate = { project: string sessionId: string @@ -2002,6 +2068,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, @@ -2010,7 +2077,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding type: 'paste', destination: 'session-opener', label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', - text: 'Before continuing, name the deliverable in one sentence (PR title, file changed, command output you expect). Stop and check with me if (a) you spend more than 10 minutes without an edit, or (b) the same approach fails twice. Do not retry past two attempts on any single fix.', + text: LOW_WORTH_OPENER, }, } } @@ -2115,6 +2182,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, @@ -2123,7 +2191,7 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId type: 'paste', destination: 'session-opener', label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', - text: 'Start fresh before continuing. Use only the current goal, the relevant files, the failing command/output, and the constraints below. Restate the working context in under 10 bullets before editing.', + text: CONTEXT_HEAVY_OPENER, }, } } @@ -2185,6 +2253,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', @@ -2274,7 +2343,7 @@ function sessionTrend( const INPUT_COST_RATIO = 0.7 const DEFAULT_COST_PER_TOKEN = 0 -function computeInputCostRate(projects: ProjectSummary[]): number { +export function computeInputCostRate(projects: ProjectSummary[]): number { const sessions = projects.flatMap(p => p.sessions) const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) const totalTokens = sessions.reduce((s, sess) => @@ -2457,6 +2526,8 @@ function renderOptimize( callCount: number, healthScore: number, healthGrade: HealthGrade, + appliedHeader?: string, + previouslyApplied?: Record, ): string { const lines: string[] = [] lines.push('') @@ -2470,6 +2541,7 @@ function renderOptimize( chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, ].join(chalk.hex(DIM)(' '))) + if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader)) lines.push('') if (findings.length === 0) { @@ -2492,7 +2564,10 @@ function renderOptimize( lines.push('') for (let i = 0; i < findings.length; i++) { - lines.push(...renderFinding(i + 1, findings[i], costRate)) + const f = findings[i]! + const appliedOn = previouslyApplied?.[f.id] + const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f + lines.push(...renderFinding(i + 1, shown, costRate)) } lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) @@ -2505,7 +2580,7 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, - opts: { format?: 'text' | 'json' } = {}, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, ): Promise { const format = opts.format ?? 'text' if (projects.length === 0 && format === 'text') { @@ -2528,7 +2603,7 @@ export async function runOptimize( return } - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied) console.log(output) } @@ -2566,6 +2641,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/src/parser.ts b/src/parser.ts index 2cab968..4d407b9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1081,7 +1081,7 @@ function applyLocalModelSavings(call: ParsedApiCall): ParsedApiCall { } } -function parseApiCall(entry: JournalEntry): ParsedApiCall | null { +export function parseApiCall(entry: JournalEntry): ParsedApiCall | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined if (!msg?.usage || !msg?.model) return null @@ -1148,7 +1148,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { }) } -function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { +export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { const firstIdxById = new Map() const lastIdxById = new Map() for (let i = 0; i < entries.length; i++) { diff --git a/tests/act-journal.test.ts b/tests/act-journal.test.ts new file mode 100644 index 0000000..5732899 --- /dev/null +++ b/tests/act-journal.test.ts @@ -0,0 +1,215 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runAction } from '../src/act/apply.js' +import { journalPath, readRecords } from '../src/act/journal.js' +import { sha256 } from '../src/act/backup.js' +import type { ActionRecord } from '../src/act/types.js' + +const roots: string[] = [] + +async function makeRoot(): Promise<{ actionsDir: string; files: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-journal-')) + roots.push(root) + const files = join(root, 'files') + await mkdir(files, { recursive: true }) + return { actionsDir: join(root, 'actions'), files } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +describe('runAction journaling', () => { + it('journals backups and afterHash for edit, create, and move ops', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.txt') + const createPath = join(files, 'new.txt') + const movePath = join(files, 'move.txt') + const moveDest = join(files, 'archive', 'move.txt') + await writeFile(editPath, 'old-edit') + await writeFile(movePath, 'move-body') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'test action', + changes: [ + { op: 'edit', path: editPath, content: 'new-edit' }, + { op: 'create', path: createPath, content: 'created' }, + { op: 'move', path: movePath, movedTo: moveDest }, + ], + }, actionsDir) + + expect(rec.status).toBe('applied') + expect(rec.findingId).toBeNull() + expect(rec.changes).toHaveLength(3) + const [edit, create, move] = rec.changes + + expect(edit!.backup).not.toBeNull() + expect(await readFile(join(actionsDir, edit!.backup!), 'utf-8')).toBe('old-edit') + expect(edit!.afterHash).toBe(sha256(Buffer.from('new-edit'))) + expect(await readFile(editPath, 'utf-8')).toBe('new-edit') + + expect(create!.backup).toBeNull() + expect(create!.afterHash).toBe(sha256(Buffer.from('created'))) + expect(await readFile(createPath, 'utf-8')).toBe('created') + + expect(move!.backup).not.toBeNull() + expect(await readFile(join(actionsDir, move!.backup!), 'utf-8')).toBe('move-body') + expect(move!.movedTo).toBe(moveDest) + expect(move!.afterHash).toBe(sha256(Buffer.from('move-body'))) + expect(await readFile(moveDest, 'utf-8')).toBe('move-body') + expect(existsSync(movePath)).toBe(false) + + const records = await readRecords(actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.id).toBe(rec.id) + }) + + it('rolls back completed mutations and writes no record when a mutation fails', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.txt') + await writeFile(editPath, 'original') + const missingSrc = join(files, 'does-not-exist.txt') + + await expect(runAction({ + kind: 'shell-config', + description: 'failing action', + changes: [ + { op: 'edit', path: editPath, content: 'changed' }, + { op: 'move', path: missingSrc, movedTo: join(files, 'dest.txt') }, + ], + }, actionsDir)).rejects.toThrow() + + expect(await readFile(editPath, 'utf-8')).toBe('original') + expect(existsSync(journalPath(actionsDir))).toBe(false) + }) + + it('skips corrupt journal lines and still loads valid records', async () => { + const { actionsDir } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + const recA = sampleRecord('11111111-1111-1111-1111-111111111111', 'first') + const recB = sampleRecord('22222222-2222-2222-2222-222222222222', 'second') + await writeFile( + journalPath(actionsDir), + JSON.stringify(recA) + '\n' + '{ this is not json\n' + JSON.stringify(recB) + '\n', + ) + + const records = await readRecords(actionsDir) + expect(records.map(r => r.id)).toEqual([recA.id, recB.id]) + expect(records.map(r => r.description)).toEqual(['first', 'second']) + }) + + it('round-trips a record through JSON (act list --json shape)', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'f.txt') + await writeFile(p, 'before') + const rec = await runAction({ + kind: 'model-default', + description: 'json shape', + changes: [{ op: 'edit', path: p, content: 'after' }], + }, actionsDir) + + const records = await readRecords(actionsDir) + const roundTripped = JSON.parse(JSON.stringify(records)) as ActionRecord[] + expect(roundTripped).toEqual(records) + + const only = roundTripped[0]! + expect(only).toMatchObject({ id: rec.id, kind: 'model-default', description: 'json shape', status: 'applied' }) + expect(typeof only.at).toBe('string') + expect(only.findingId).toBeNull() + expect(only.changes[0]).toMatchObject({ path: p, op: 'edit' }) + expect(only.changes[0]!.backup).not.toBeNull() + expect(typeof only.changes[0]!.afterHash).toBe('string') + }) +}) + +describe('action lock', () => { + it('refuses to run while a fresh foreign lock is held', async () => { + const { actionsDir, files } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + await writeFile(join(actionsDir, '.lock'), '') + const target = join(files, 'blocked.txt') + + await expect(runAction({ + kind: 'shell-config', + description: 'blocked by lock', + changes: [{ op: 'create', path: target, content: 'x' }], + }, actionsDir)).rejects.toThrow(/in progress/) + + expect(existsSync(target)).toBe(false) + }) + + it('takes over a lock whose mtime is older than 60s', async () => { + const { actionsDir, files } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + const lock = join(actionsDir, '.lock') + await writeFile(lock, JSON.stringify({ pid: 1, at: 0 })) + const past = new Date(Date.now() - 61_000) + await utimes(lock, past, past) + const target = join(files, 'takeover.txt') + + const rec = await runAction({ + kind: 'shell-config', + description: 'stale takeover', + changes: [{ op: 'create', path: target, content: 'x' }], + }, actionsDir) + + expect(rec.status).toBe('applied') + expect(await readFile(target, 'utf-8')).toBe('x') + expect(existsSync(lock)).toBe(false) + }) +}) + +describe('act list --json (CLI)', () => { + it('prints full records as JSON, newest first', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-act-cli-')) + roots.push(home) + const actionsDir = join(home, '.config', 'codeburn', 'actions') + const files = join(home, 'work') + await mkdir(files, { recursive: true }) + const p1 = join(files, 'a.txt') + const p2 = join(files, 'b.txt') + await writeFile(p1, 'a') + await writeFile(p2, 'b') + const recOlder = await runAction({ + kind: 'mcp-remove', description: 'older', changes: [{ op: 'edit', path: p1, content: 'a2' }], + }, actionsDir) + const recNewer = await runAction({ + kind: 'mcp-remove', description: 'newer', changes: [{ op: 'edit', path: p2, content: 'b2' }], + }, actionsDir) + + // Anchor the spawn to this checkout so running vitest from another cwd + // cannot execute a different checkout's cli.ts. + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + const res = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'act', 'list', '--json'], { + cwd: repoRoot, + env: { ...process.env, HOME: home, USERPROFILE: home, HOMEPATH: home, HOMEDRIVE: '' }, + encoding: 'utf-8', + }) + + expect(res.status).toBe(0) + const parsed = JSON.parse(res.stdout) as ActionRecord[] + expect(parsed.map(r => r.id)).toEqual([recNewer.id, recOlder.id]) + expect(parsed[0]).toMatchObject({ kind: 'mcp-remove', description: 'newer', status: 'applied', findingId: null }) + expect(parsed[0]!.changes[0]).toMatchObject({ path: p2, op: 'edit' }) + expect(typeof parsed[0]!.changes[0]!.afterHash).toBe('string') + expect(parsed[0]!.changes[0]!.backup).not.toBeNull() + }, 20_000) +}) + +function sampleRecord(id: string, description: string): ActionRecord { + return { + id, + at: new Date().toISOString(), + kind: 'mcp-remove', + findingId: null, + description, + changes: [], + status: 'applied', + } +} diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts new file mode 100644 index 0000000..ae0664c --- /dev/null +++ b/tests/act-report.test.ts @@ -0,0 +1,435 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { journalPath } from '../src/act/journal.js' +import { + buildActReportJson, + buildOptimizeAppliedHeader, + computeActReport, + renderActReport, +} from '../src/act/report.js' +import type { ActionRecord } from '../src/act/types.js' +import type { ProjectSummary } from '../src/types.js' + +type Session = ProjectSummary['sessions'][number] + +const roots: string[] = [] +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +const NOW = new Date('2026-07-01T00:00:00Z') +function daysAgo(n: number): string { + return new Date(NOW.getTime() - n * 86_400_000).toISOString() +} + +async function writeJournal(records: unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-report-')) + roots.push(root) + const actionsDir = join(root, 'actions') + await mkdir(actionsDir, { recursive: true }) + await writeFile(journalPath(actionsDir), records.map(r => JSON.stringify(r)).join('\n') + (records.length ? '\n' : '')) + return actionsDir +} + +function makeSession(id: string, firstTimestamp: string, over: Partial = {}): Session { + return { + sessionId: id, + project: 'app', + firstTimestamp, + lastTimestamp: firstTimestamp, + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 1000, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as Session['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...over, + } +} + +function projectOf(sessions: Session[]): ProjectSummary { + return { + project: 'app', + projectPath: '/tmp/app', + sessions, + totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0), + totalSavingsUSD: 0, + totalApiCalls: sessions.length, + totalProxiedCostUSD: 0, + } +} + +function sessionsAt(count: number, ts: string, over: Partial = {}): Session[] { + return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, ts, over)) +} + +function mcpRecord(over: Partial = {}): ActionRecord { + const at = daysAgo(10) + return { + id: 'a1', + at, + kind: 'mcp-remove', + findingId: 'unused-mcp', + description: 'Remove an MCP server from config', + changes: [], + status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 56_000, sessions: 28, metrics: { 'brave-search': 2000 } }, + ...over, + } +} + +const load = (projects: ProjectSummary[]) => async () => projects + +describe('mcp realized delta', () => { + it('multiplies baseline tokens-per-session by post-window sessions (exact)', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.rows).toHaveLength(1) + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(40_000) // 2000 tokens/session * 20 sessions + expect(row.estimatedAtApply).toBe(56_000) + expect(row.estimatedForWindow).toBe(40_000) // window-scaled: 2000 * 20 + expect(row.confidence).toBe('normal') + expect(report.totalRealizedTokens).toBe(40_000) + }) + + it('subtracts keeper sessions that still load the server for project-scope, not a revert', async () => { + const rec = mcpRecord({ kind: 'mcp-project-scope', findingId: 'mcp-project-scope', description: 'Project-scope an MCP server' }) + const actionsDir = await writeJournal([rec]) + const keepers = sessionsAt(5, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] }) + const cold = sessionsAt(15, daysAgo(5)) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([...cold, ...keepers])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(30_000) // 2000 x (20 - 5 still loading) + expect(row.estimatedForWindow).toBe(40_000) // 2000 x all 20 window sessions + }) + + it('reports "reverted" with zero savings when the server reappears in the window', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(back)]) }) + + const row = report.rows[0]! + expect(row.status).toBe('reverted') + expect(row.realizedTokens).toBe(0) + expect(row.note).toMatch(/reverted by user/) + expect(report.totalRealizedTokens).toBe(0) + }) +}) + +describe('confidence markers', () => { + it('marks low when fewer than 20 post-window sessions', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(20_000) // 2000 * 10 + expect(row.confidence).toBe('low') + }) + + it('marks low when volume shifts more than 2x versus baseline', async () => { + // 25 post-window sessions (>= 20, so not the count rule) over 10 days is + // 2.5/day against a 1/day baseline (14 sessions / 14 days) -> 2.5x shift. + const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 14, metrics: { 'brave-search': 2000 } } }) + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(25, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.confidence).toBe('low') + }) + + it('stays normal when volume is comparable to baseline', async () => { + const actionsDir = await writeJournal([mcpRecord()]) // baseline 28/14 = 2/day + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) // 20/10 = 2/day + expect(report.rows[0]!.confidence).toBe('normal') + }) +}) + +describe('eligibility', () => { + it('excludes undone actions and actions younger than 3 days', async () => { + const records = [ + mcpRecord({ id: 'old', at: daysAgo(10) }), + mcpRecord({ id: 'young', at: daysAgo(1) }), + mcpRecord({ id: 'undone', at: daysAgo(20), status: 'undone' }), + ] + const actionsDir = await writeJournal(records) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.rows).toHaveLength(1) + expect(report.rows[0]!.id).toBe('old') + expect(report.activeCount).toBe(2) // old + young are applied; undone is not + }) +}) + +describe('read-edit realized delta', () => { + it('credits the reduction in the read deficit using the detector estimate math', async () => { + // Baseline ratio 1:1 (deficit 3 reads/edit). After window: 120 reads / 40 + // edits = 3:1 (deficit 1). Credit (3 - 1) * 40 edits * 600 = 48000. + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'r1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio', + description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 12_000, sessions: 28, metrics: { reads: 10, edits: 10 } }, + } + const actionsDir = await writeJournal([rec]) + const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 120 }, Edit: { calls: 40 } } }) + const filler = sessionsAt(19, daysAgo(4)) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session, ...filler])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(48_000) + expect(row.estimatedAtApply).toBe(12_000) + expect(row.estimatedForWindow).toBe(72_000) // deficitThen 3 x 40 edits x 600, same denominator as realized + expect(row.realizedTokens).toBeLessThanOrEqual(row.estimatedForWindow) + expect(row.note).toMatch(/1\.0:1 -> 3\.0:1/) + }) +}) + +describe('round-down discipline', () => { + it('floors non-integer mcp products, never rounds up', async () => { + const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 5000, sessions: 3, metrics: { 'brave-search': 700.7 } } }) + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(3, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(2102) // floor(700.7 x 3 = 2102.1); ceil would be 2103 + expect(row.estimatedForWindow).toBe(2102) + }) + + it('floors non-integer read-edit products, never rounds up', async () => { + // deficitThen = 4 - 10/7 = 18/7; deficitNow = 4 - 20/10 = 2. + // realized = floor((18/7 - 2) x 10 x 600) = floor(3428.57...) = 3428. + // window estimate = floor(18/7 x 10 x 600) = floor(15428.57...) = 15428. + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'rf1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio', + description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 9000, sessions: 28, metrics: { reads: 10, edits: 7 } }, + } + const actionsDir = await writeJournal([rec]) + const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 20 }, Edit: { calls: 10 } } }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(3428) // ceil would be 3429 + expect(row.estimatedForWindow).toBe(15_428) // ceil would be 15429 + }) +}) + +describe('archive realized delta', () => { + it('measures per-item definition tokens times sessions and detects un-archive', async () => { + const at = daysAgo(10) + const kept = join(tmpdir(), 'codeburn-act-report-absent-skill-xyz') // absent -> not reverted + const base = { windowDays: 14, capturedAt: at, estimatedTokens: 160, sessions: 28, metrics: { 'skill-a': 80, 'skill-b': 80 } } + const rec: ActionRecord = { + id: 'ar1', at, kind: 'archive-skill', findingId: 'unused-skills', + description: 'Archive 2 unused skills', status: 'applied', + changes: [{ path: kept, backup: null, op: 'move', movedTo: kept + '.archived', afterHash: '' }], + baseline: base, + } + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report.rows[0]!.status).toBe('measured') + expect(report.rows[0]!.realizedTokens).toBe(3200) // 160 tokens/session * 20 + // Tautology expected: estimate and realized share the formula when nothing + // reverted; the measured signal is the session count and the revert check. + expect(report.rows[0]!.estimatedForWindow).toBe(report.rows[0]!.realizedTokens) + + // Now the original path exists again -> reverted, zero savings. + const restoredPath = join(actionsDir, 'restored-skill') + await writeFile(restoredPath, 'x') + const rec2: ActionRecord = { ...rec, changes: [{ path: restoredPath, backup: null, op: 'move', movedTo: restoredPath + '.archived', afterHash: '' }] } + const dir2 = await writeJournal([rec2]) + const report2 = await computeActReport({ actionsDir: dir2, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report2.rows[0]!.status).toBe('reverted') + expect(report2.rows[0]!.realizedTokens).toBe(0) + }) +}) + +describe('unmeasured kinds', () => { + it('marks bash cap not measurable but keeps the estimate visible', async () => { + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'b1', at, kind: 'shell-config', findingId: 'bash-output-cap', + description: 'Set the bash output cap', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 3750, sessions: 28, metrics: { calls: 200 } }, + } + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report.rows[0]!.status).toBe('not-measurable') + expect(report.rows[0]!.estimatedAtApply).toBe(3750) + expect(report.rows[0]!.estimatedForWindow).toBe(3750) // no window scaling for unmeasured kinds + expect(report.measuredCount).toBe(0) + }) + + it('marks mcp and archive not measurable when the window has no sessions yet', async () => { + const at = daysAgo(10) + const arch: ActionRecord = { + id: 'z2', at, kind: 'archive-skill', findingId: 'unused-skills', + description: 'Archive 1 unused skill', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 80, sessions: 28, metrics: { 'skill-a': 80 } }, + } + const actionsDir = await writeJournal([mcpRecord(), arch]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([]) }) + + expect(report.rows).toHaveLength(2) + for (const row of report.rows) { + expect(row.status).toBe('not-measurable') + expect(row.note).toMatch(/no sessions in the window yet/) + expect(row.realizedTokens).toBe(0) + } + expect(report.measuredCount).toBe(0) + }) +}) + +describe('journal robustness', () => { + const missingAt = { id: 'm1', kind: 'mcp-remove', status: 'applied', description: 'missing at', changes: [] } + const numericAt = { id: 'm2', at: 12345, kind: 'mcp-remove', status: 'applied', description: 'numeric at', changes: [] } + + it('skips malformed records with a note instead of crashing, and drops the optimize header', async () => { + const actionsDir = await writeJournal([missingAt, numericAt]) + const report = await computeActReport({ + actionsDir, now: NOW, + loadProjects: async () => { throw new Error('should not scan when no eligible records remain') }, + }) + + expect(report.malformedRecords).toBe(2) + expect(report.rows).toHaveLength(0) + expect(report.activeCount).toBe(0) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + expect(renderActReport(report)).toMatch(/2 malformed records skipped/) + }) + + it('still measures valid records alongside malformed ones', async () => { + const actionsDir = await writeJournal([missingAt, mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.malformedRecords).toBe(1) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]!.realizedTokens).toBe(40_000) + expect(renderActReport(report)).toMatch(/1 malformed record skipped/) + }) +}) + +describe('optimize header', () => { + it('appears only when a normal-confidence measured token action exists', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const header = buildOptimizeAppliedHeader(report) + expect(header).toMatch(/^Applied fixes: 1 active, realized ~40\.0K tokens.*over 10 days\. Details: codeburn act report$/) + }) + + it('renders no header when every measured row is low confidence (under-claim)', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) }) + + expect(report.rows[0]!.status).toBe('measured') + expect(report.rows[0]!.confidence).toBe('low') + expect(report.rows[0]!.realizedTokens).toBe(20_000) // still visible in act report + expect(report.totalRealizedTokens).toBe(20_000) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + }) + + it('sums only normal-confidence rows into the header total', async () => { + // r1: baseline 28/14d = 2/day vs post 20/10d = 2/day -> normal. + // r2: baseline 100/14d = 7.1/day vs 2/day -> >2x shift -> low. + const r1 = mcpRecord({ id: 'n1' }) + const r2 = mcpRecord({ + id: 'l1', + baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 100, metrics: { 'other-server': 2000 } }, + }) + const actionsDir = await writeJournal([r1, r2]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.totalRealizedTokens).toBe(80_000) // both stay visible in act report + const header = buildOptimizeAppliedHeader(report) + expect(header).toMatch(/^Applied fixes: 2 active, realized ~40\.0K tokens/) + }) + + it('returns null and never scans when the journal has no eligible actions', async () => { + const emptyDir = await writeJournal([]) + const report = await computeActReport({ + actionsDir: emptyDir, now: NOW, + loadProjects: async () => { throw new Error('should not scan for an empty journal') }, + }) + expect(report.rows).toHaveLength(0) + expect(report.activeCount).toBe(0) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + }) + + it('records the earliest apply date per finding for re-flagging', async () => { + const records = [ + mcpRecord({ id: 'x1', at: daysAgo(9), findingId: 'unused-mcp' }), + mcpRecord({ id: 'x2', at: daysAgo(4), findingId: 'unused-mcp' }), + ] + const actionsDir = await writeJournal(records) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(3)))]) }) + expect(report.appliedByFinding['unused-mcp']).toBe(daysAgo(9).slice(0, 10)) + }) +}) + +describe('json + render shape', () => { + it('mirrors the rows and totals in --json', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const json = buildActReportJson(report) as { + actions: Array> + totals: Record + footer: string + windowCapDays: number + } + + expect(Array.isArray(json.actions)).toBe(true) + expect(json.actions[0]).toMatchObject({ + kind: 'mcp-remove', + estimatedAtApply: 56_000, + estimatedForWindow: 40_000, + realizedTokens: 40_000, + status: 'measured', + confidence: 'normal', + }) + expect(json.totals).toMatchObject({ realizedTokens: 40_000, measuredActions: 1, activeActions: 1 }) + expect(json.windowCapDays).toBe(30) + expect((json as { malformedRecords?: number }).malformedRecords).toBe(0) + expect(typeof json.footer).toBe('string') + expect(json.footer).toMatch(/correlation/) + }) + + it('renders an empty state without a table when nothing is measurable', async () => { + const emptyDir = await writeJournal([]) + const report = await computeActReport({ actionsDir: emptyDir, now: NOW, loadProjects: async () => [] }) + const out = renderActReport(report) + expect(out).toMatch(/No applied actions to measure yet/) + expect(out).not.toMatch(/Total realized/) + }) + + it('renders a table with a total row when measurements exist', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const out = renderActReport(report) + expect(out).toMatch(/Total realized/) + expect(out).toMatch(/40\.0K/) + expect(out).toMatch(/measures only its own metric/) + expect(out).toMatch(/scaled to the measured window/) + }) +}) diff --git a/tests/act-undo.test.ts b/tests/act-undo.test.ts new file mode 100644 index 0000000..3d37012 --- /dev/null +++ b/tests/act-undo.test.ts @@ -0,0 +1,307 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runAction } from '../src/act/apply.js' +import { appendRecord, readRecords } from '../src/act/journal.js' +import { DriftError, undoAction } from '../src/act/undo.js' +import type { ActionRecord } from '../src/act/types.js' + +const roots: string[] = [] + +async function makeRoot(): Promise<{ actionsDir: string; files: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-undo-')) + roots.push(root) + const files = join(root, 'files') + await mkdir(files, { recursive: true }) + return { actionsDir: join(root, 'actions'), files } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +describe('undoAction', () => { + it('restores byte-identical content for edit, create, and move, then refuses a second undo', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.bin') + const createPath = join(files, 'created.bin') + const movePath = join(files, 'move.bin') + const moveDest = join(files, 'sub', 'move.bin') + const editOriginal = Buffer.from([0, 1, 2, 3, 255, 254]) + const moveOriginal = Buffer.from([10, 20, 30, 40]) + await writeFile(editPath, editOriginal) + await writeFile(movePath, moveOriginal) + + const rec = await runAction({ + kind: 'archive-skill', + description: 'undo test', + changes: [ + { op: 'edit', path: editPath, content: Buffer.from([9, 9, 9]) }, + { op: 'create', path: createPath, content: Buffer.from([7, 7]) }, + { op: 'move', path: movePath, movedTo: moveDest }, + ], + }, actionsDir) + + // 8-char prefix is accepted + await undoAction({ id: rec.id.slice(0, 8) }, { actionsDir }) + + expect(Buffer.compare(await readFile(editPath), editOriginal)).toBe(0) + expect(existsSync(createPath)).toBe(false) + expect(existsSync(moveDest)).toBe(false) + expect(Buffer.compare(await readFile(movePath), moveOriginal)).toBe(0) + + const records = await readRecords(actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.status).toBe('undone') + expect(records[0]!.undoneAt).toBeTruthy() + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toThrow(/already undone/) + }) + + it('undoes the most recent action with --last and leaves earlier actions applied', async () => { + const { actionsDir, files } = await makeRoot() + const first = join(files, 'first.txt') + const second = join(files, 'second.txt') + await writeFile(first, 'first-old') + await writeFile(second, 'second-old') + + const recFirst = await runAction({ + kind: 'claude-md-rule', description: 'first', changes: [{ op: 'edit', path: first, content: 'first-new' }], + }, actionsDir) + await runAction({ + kind: 'claude-md-rule', description: 'second', changes: [{ op: 'edit', path: second, content: 'second-new' }], + }, actionsDir) + + const undone = await undoAction({ last: true }, { actionsDir }) + expect(undone.description).toBe('second') + expect(await readFile(second, 'utf-8')).toBe('second-old') + expect(await readFile(first, 'utf-8')).toBe('first-new') + + const records = await readRecords(actionsDir) + expect(records.find(r => r.id === recFirst.id)!.status).toBe('applied') + }) + + it('refuses to undo a drifted file, but --force proceeds', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'drift.txt') + await writeFile(p, 'original') + const rec = await runAction({ + kind: 'claude-md-rule', description: 'drift', changes: [{ op: 'edit', path: p, content: 'applied' }], + }, actionsDir) + + await writeFile(p, 'user-modified') + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError) + expect((await readRecords(actionsDir))[0]!.status).toBe('applied') + expect(await readFile(p, 'utf-8')).toBe('user-modified') + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(p, 'utf-8')).toBe('original') + expect((await readRecords(actionsDir))[0]!.status).toBe('undone') + }) + + it('reverts changes newest-first so overlapping changes restore the original state', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'orig') + + const rec = await runAction({ + kind: 'shell-config', + description: 'move then edit', + changes: [ + { op: 'move', path: src, movedTo: dest }, + { op: 'edit', path: dest, content: 'edited' }, + ], + }, actionsDir) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(src, 'utf-8')).toBe('orig') + expect(existsSync(dest)).toBe(false) + }) + + it('refuses to undo a move when the original path is occupied, then --force overwrites', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'moved-bytes') + const rec = await runAction({ + kind: 'archive-skill', description: 'occupied', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await writeFile(src, 'squatter') + + const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e) + expect(err).toBeInstanceOf(DriftError) + expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true) + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(src, 'utf-8')).toBe('moved-bytes') + expect(existsSync(dest)).toBe(false) + }) + + it('snapshots an existing move destination and restores both files on undo', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'src-bytes') + await writeFile(dest, 'dest-bytes') + + const rec = await runAction({ + kind: 'archive-agent', description: 'move onto dest', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.destBackup).not.toBeNull() + expect(await readFile(dest, 'utf-8')).toBe('src-bytes') + expect(await readFile(join(actionsDir, rec.changes[0]!.destBackup!), 'utf-8')).toBe('dest-bytes') + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(src, 'utf-8')).toBe('src-bytes') + expect(await readFile(dest, 'utf-8')).toBe('dest-bytes') + }) + + it('force-undo of a move whose moved file is gone restores from backup and flips status', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'precious') + const rec = await runAction({ + kind: 'archive-command', description: 'gone', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await rm(dest) + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError) + const undone = await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(undone.status).toBe('undone') + expect(await readFile(src, 'utf-8')).toBe('precious') + }) + + it('undoing a create that overwrote an existing file restores the prior bytes', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'exists.txt') + await writeFile(p, 'prior') + + const rec = await runAction({ + kind: 'guard-install', description: 'create over existing', changes: [{ op: 'create', path: p, content: 'new' }], + }, actionsDir) + expect(rec.changes[0]!.backup).not.toBeNull() + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(p, 'utf-8')).toBe('prior') + }) + + it('undoes a plan that touches the same path twice back to the original bytes', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'twice.txt') + await writeFile(p, 'v0') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'same path twice', + changes: [ + { op: 'edit', path: p, content: 'v1' }, + { op: 'edit', path: p, content: 'v2' }, + ], + }, actionsDir) + expect(rec.changes[0]!.afterHash).toBe(rec.changes[1]!.afterHash) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(p, 'utf-8')).toBe('v0') + }) + + it('rejects an ambiguous id prefix with the match count', async () => { + const { actionsDir } = await makeRoot() + await appendRecord(actionsDir, bareRecord('aaaaaaaa-1111-4111-8111-111111111111', 'one')) + await appendRecord(actionsDir, bareRecord('aaaaaaaa-2222-4222-8222-222222222222', 'two')) + + await expect(undoAction({ id: 'aaaaaaaa' }, { actionsDir })).rejects.toThrow(/matches 2 actions/) + }) + + it('archives a directory and undo restores the tree with nested content byte-identical', async () => { + const { actionsDir, files } = await makeRoot() + const dir = join(files, 'skill') + const dest = join(files, '.archived', 'skill') + const nestedBytes = Buffer.from([1, 2, 3, 250, 0]) + await mkdir(join(dir, 'nested'), { recursive: true }) + await writeFile(join(dir, 'SKILL.md'), 'skill body') + await writeFile(join(dir, 'nested', 'data.bin'), nestedBytes) + + const rec = await runAction({ + kind: 'archive-skill', + description: 'archive dir', + changes: [{ op: 'move', path: dir, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.afterHash).toBe('') + expect(rec.changes[0]!.backup).not.toBeNull() + expect(existsSync(dir)).toBe(false) + expect(await readFile(join(dest, 'SKILL.md'), 'utf-8')).toBe('skill body') + + await undoAction({ id: rec.id }, { actionsDir }) + expect(existsSync(dest)).toBe(false) + expect(await readFile(join(dir, 'SKILL.md'), 'utf-8')).toBe('skill body') + expect(Buffer.compare(await readFile(join(dir, 'nested', 'data.bin')), nestedBytes)).toBe(0) + }) + + it('moves a directory onto an existing destination directory and undo restores both trees', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'agent') + const dest = join(files, 'agent-archived') + await mkdir(src, { recursive: true }) + await mkdir(dest, { recursive: true }) + await writeFile(join(src, 'agent.md'), 'src tree') + await writeFile(join(dest, 'old.md'), 'dest tree') + + const rec = await runAction({ + kind: 'archive-agent', + description: 'dir onto dir', + changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.destBackup).not.toBeNull() + expect(await readFile(join(dest, 'agent.md'), 'utf-8')).toBe('src tree') + expect(existsSync(join(dest, 'old.md'))).toBe(false) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(join(src, 'agent.md'), 'utf-8')).toBe('src tree') + expect(await readFile(join(dest, 'old.md'), 'utf-8')).toBe('dest tree') + expect(existsSync(join(dest, 'agent.md'))).toBe(false) + }) + + it('refuses dir-move undo when the original path is occupied, then --force overwrites', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'cmd') + const dest = join(files, 'cmd-archived') + await mkdir(src, { recursive: true }) + await writeFile(join(src, 'cmd.md'), 'body') + const rec = await runAction({ + kind: 'archive-command', + description: 'occupied dir', + changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await mkdir(src, { recursive: true }) + await writeFile(join(src, 'squatter.md'), 'squatter') + + const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e) + expect(err).toBeInstanceOf(DriftError) + expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true) + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(join(src, 'cmd.md'), 'utf-8')).toBe('body') + expect(existsSync(join(src, 'squatter.md'))).toBe(false) + expect(existsSync(dest)).toBe(false) + }) +}) + +function bareRecord(id: string, description: string): ActionRecord { + return { + id, + at: new Date().toISOString(), + kind: 'mcp-remove', + findingId: null, + description, + changes: [], + status: 'applied', + } +} diff --git a/tests/guard-hooks.test.ts b/tests/guard-hooks.test.ts new file mode 100644 index 0000000..ef5d6b2 --- /dev/null +++ b/tests/guard-hooks.test.ts @@ -0,0 +1,371 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { dedupeStreamingMessageIds, parseApiCall, parseJsonlLine } from '../src/parser.js' +import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js' +import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js' +import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js' +import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js' +import type { ProjectSummary } from '../src/types.js' + +const roots: string[] = [] +async function tmp(): Promise { + const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-')) + roots.push(d) + return d +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +type Tool = { name: string; input?: Record } +function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string { + const content: Record[] = [{ type: 'text', text: 'ok' }] + for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} }) + return JSON.stringify({ + type: 'assistant', + timestamp: o.ts ?? '2026-07-01T00:00:00.000Z', + message: { + model: 'claude-sonnet-4-20250514', + id, + type: 'message', + role: 'assistant', + usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 }, + content, + }, + }) + '\n' +} + +async function transcript(lines: string[]): Promise { + const dir = await tmp() + const path = join(dir, 'session.jsonl') + await writeFile(path, lines.join(''), 'utf-8') + return path +} + +const SID = 'sess-1' +function hookInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' }) +} + +// The shipped pipeline's cost for a transcript: parse every line, dedupe +// streaming copies last-wins exactly like parseSessionFile does, sum call +// costs. The guard's incremental fold must match this to the cent. +async function shippedDedupedCost(path: string): Promise { + const entries = (await readFile(path, 'utf-8')) + .split('\n') + .filter(l => l.trim()) + .map(l => parseJsonlLine(l)) + .filter((e): e is NonNullable> => e !== null) + return dedupeStreamingMessageIds(entries).reduce((s, e) => s + (parseApiCall(e)?.costUSD ?? 0), 0) +} + +describe('incremental session cache', () => { + it('parses only the appended tail and totals match a cold parse', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + expect(r1.resumedFrom).toBe(0) + expect(r1.cache.costUSD).toBeGreaterThan(0) + const offset1 = r1.cache.byteOffset + + await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8') + const size = (await stat(path)).size + + const prev = await readCache(SID, base) + const r2 = await computeSessionUsage(prev, path) + + // Bytes-read assertion: the second pass resumed exactly where the first + // stopped and consumed only the appended region (not the whole file). + expect(r2.resumedFrom).toBe(offset1) + expect(r2.cache.byteOffset).toBe(size) + expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0) + expect(r2.resumedFrom).toBeLessThan(size) + + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD) + }) + + it('resets to a cold parse when the transcript shrinks (rotation)', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + + await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content + const r2 = await computeSessionUsage(await readCache(SID, base), path) + expect(r2.resumedFrom).toBe(0) + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + }) +}) + +describe('streaming duplicates (per-message-id replace)', () => { + it('counts a message written 3x with identical usage once, matching the shipped dedup', async () => { + const dup = assistantLine('msg-dup', { inTok: 500_000, outTok: 100_000 }) + const path = await transcript([dup, dup, dup, assistantLine('msg-other')]) + const singlePath = await transcript([dup, assistantLine('msg-other')]) + + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + const single = await computeSessionUsage(emptyCache(SID), singlePath) + expect(cache.costUSD).toBeCloseTo(single.cache.costUSD, 10) + }) + + it('keeps the last copy when streamed usage grows', async () => { + const copies = [10_000, 50_000, 120_000].map(outTok => assistantLine('msg-grow', { outTok })) + const path = await transcript(copies) + const lastOnly = await transcript([copies[2]!]) + + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + const last = await computeSessionUsage(emptyCache(SID), lastOnly) + expect(cache.costUSD).toBeCloseTo(last.cache.costUSD, 10) + }) + + it('replaces across incremental invocations when a later copy arrives', async () => { + const base = await tmp() + const path = await transcript([assistantLine('msg-x', { outTok: 20_000 })]) + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + + await appendFile(path, assistantLine('msg-x', { outTok: 90_000 }) + assistantLine('msg-y'), 'utf-8') + const r2 = await computeSessionUsage(await readCache(SID, base), path) + expect(r2.resumedFrom).toBe(r1.cache.byteOffset) + expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + }) +}) + +describe('trailing complete line without newline', () => { + it('does not double count the line once it completes (A,B,C then D)', async () => { + const base = await tmp() + const a = assistantLine('msg-a') + const b = assistantLine('msg-b') + const c = assistantLine('msg-c') + const d = assistantLine('msg-d') + const dir = await tmp() + const path = join(dir, 'session.jsonl') + await writeFile(path, a + b + c.slice(0, -1), 'utf-8') // C complete but unterminated + + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + // C was folded, but the offset stops after B's newline. + expect(r1.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + expect(r1.cache.byteOffset).toBe((a + b).length) + + await appendFile(path, '\n' + d, 'utf-8') + const r2 = await computeSessionUsage(await readCache(SID, base), path) + // C is re-read as a replace, not a second add: totals equal a cold parse. + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + }) +}) + +describe('git commit detection', () => { + it('requires commit to be the git subcommand', async () => { + const cases: [string, boolean][] = [ + ['git commit -m x', true], + ['git -c user.name=x commit', true], + ['npm test && git commit -am done', true], + ['git add src\ngit commit -m "step"', true], // newline-separated multi-command Bash call + ['git log --grep commit', false], + ['git diff && echo commit', false], + ['echo git then commit', false], + ['git diff\ncommit notes to self', false], // commit on the next line is not a git subcommand + ] + for (const [command, expected] of cases) { + const path = await transcript([assistantLine('msg-1', { tools: [{ name: 'Bash', input: { command } }] })]) + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.sawGitCommit, command).toBe(expected) + } + }) +}) + +describe('budget hook (PreToolUse)', () => { + async function costOf(path: string): Promise { + return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + } + + it('stays silent below the soft cap', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('warns once on the soft cap, then suppresses the repeat', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base) + + const first = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(JSON.parse(first).systemMessage).toContain('soft cap') + const second = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(second).toBe('') + }) + + it('blocks on the hard cap with a deny decision, and allow lifts it', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base) + + const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base })) + expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse') + expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny') + expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow') + + await writeAllow(SID, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('disables the cap when the threshold is null', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) +}) + +describe('yield checkpoint (Stop)', () => { + function stopInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' }) + } + async function withCheckpoint(base: string, path: string): Promise { + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base) + } + + it('fires once for an expensive no-edit no-commit session', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await withCheckpoint(base, path) + const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base })) + expect(first.systemMessage).toContain('no edits or commits') + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session + }) + + it('stays silent when cost is below the checkpoint', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session made an edit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session ran git commit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) +}) + +describe('session opener (SessionStart)', () => { + it('emits the matching opener text for a flagged project and nothing when stale', async () => { + const base = await tmp() + const projectPath = '/tmp/flagged-project' + const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] } + await writeFlags(flags, base) + + const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' }) + const out = JSON.parse(await runGuardHook('sessionstart', input, { base })) + expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart') + expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER') + + // Unflagged cwd -> nothing. + const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' }) + expect(await runGuardHook('sessionstart', other, { base })).toBe('') + + // Stale flag list (> 7 days) -> nothing. + const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects } + await writeFlags(stale, base) + expect(await runGuardHook('sessionstart', input, { base })).toBe('') + }) + + it('builds flags from optimize candidate detectors and matches by project path', async () => { + // A project whose only session has real spend but zero edit turns is a + // low-worth candidate; buildFlags should flag its projectPath. + const projects = [lowWorthProject()] + const flags = await buildFlags(projects as unknown as ProjectSummary[]) + expect(flags.projects.length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches + expect(matchFlag(flags, '/repo/beta')).toEqual([]) + }) +}) + +describe('fail-open: malformed stdin', () => { + it('every handler exits with empty output on garbage input', async () => { + const base = await tmp() + for (const ev of ['pretooluse', 'sessionstart', 'stop']) { + expect(await runGuardHook(ev, 'not json {', { base })).toBe('') + expect(await runGuardHook(ev, '', { base })).toBe('') + } + expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('') + expect(await runGuardStatusline('not json {', { base })).toBe('') + }) + + it('handlers stay silent when the transcript path is missing', async () => { + const base = await tmp() + const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' }) + expect(await runGuardHook('pretooluse', noPath, { base })).toBe('') + expect(await runGuardHook('stop', noPath, { base })).toBe('') + }) +}) + +describe('statusline', () => { + it('prints one line with the session cost', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base }) + expect(out.startsWith('codeburn guard $')).toBe(true) + expect(out).not.toContain('\n') + }) +}) + +// A minimal ProjectSummary shaped just enough for findLowWorthCandidates: +// meaningful cost, no delivery command, and no edit turns. +function lowWorthProject(): Record { + const turn = { + userMessage: 'do a thing', + assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }], + timestamp: '2026-07-01T00:00:00Z', + sessionId: 's-alpha', + category: 'exploration', + retries: 0, + hasEdits: false, + } + const session = { + sessionId: 's-alpha', + project: 'alpha', + firstTimestamp: '2026-07-01T00:00:00Z', + lastTimestamp: '2026-07-01T01:00:00Z', + totalCostUSD: 40, + totalSavingsUSD: 0, + totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [turn], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {}, + } + return { + project: 'alpha', + projectPath: '/repo/alpha', + sessions: [session], + totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0, + } +} diff --git a/tests/guard-install.test.ts b/tests/guard-install.test.ts new file mode 100644 index 0000000..8c1e877 --- /dev/null +++ b/tests/guard-install.test.ts @@ -0,0 +1,158 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runAction } from '../src/act/apply.js' +import { readRecords } from '../src/act/journal.js' +import { + buildInstall, buildUninstall, inspectInstall, settingsPathFor, + GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND, +} from '../src/guard/settings.js' + +const roots: string[] = [] +async function makeRoot(): Promise<{ settings: string; actionsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-')) + roots.push(root) + await mkdir(join(root, '.claude'), { recursive: true }) + return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') } +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +function canonical(obj: unknown): string { + return JSON.stringify(obj, null, 2) + '\n' +} +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf-8')) +} + +describe('settingsPathFor', () => { + it('resolves project (default) and global scopes', () => { + expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json')) + expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json')) + expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json')) + }) +}) + +describe('guard install', () => { + it('creates settings with all three hook events when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + const built = buildInstall(settings) + expect(built.plan).not.toBeNull() + expect(built.plan!.kind).toBe('guard-install') + await runAction(built.plan!, actionsDir) + + const doc = await readJson(settings) + expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`) + expect(doc.hooks.SessionStart[0].matcher).toBe('startup') + expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher + // journaled + undoable + const records = await readRecords(actionsDir) + expect(records[0]!.kind).toBe('guard-install') + }) + + it('appends to pre-existing user hooks without disturbing them', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toContain('my-own-hook.sh') + expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`) + }) + + it('is idempotent: re-install adds nothing', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings).plan!, actionsDir) + const again = buildInstall(settings) + expect(again.plan).toBeNull() + expect(again.notes.join(' ')).toContain('already present') + }) + + it('configures the statusline only when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND) + + // A pre-existing statusline is refused, not overwritten. + const { settings: s2, actionsDir: a2 } = await makeRoot() + await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } })) + const built = buildInstall(s2, { statusline: true }) + expect(built.notes.join(' ')).toContain('statusline is already configured') + await runAction(built.plan!, a2) + expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh') + }) + + it('refuses to apply a plan when the settings file changed after it was built', async () => { + const { settings, actionsDir } = await makeRoot() + await writeFile(settings, canonical({ permissions: { allow: [] } })) + const built = buildInstall(settings) + expect(built.plan).not.toBeNull() + + const concurrent = canonical({ permissions: { allow: ['Bash(ls:*)'] } }) + await writeFile(settings, concurrent) + + await expect(runAction(built.plan!, actionsDir)).rejects.toThrow(/changed since the plan was built/) + expect(await readFile(settings, 'utf-8')).toBe(concurrent) // the concurrent edit survives + expect(await readRecords(actionsDir)).toEqual([]) // nothing journaled + }) +}) + +describe('guard uninstall', () => { + it('removes exactly our entries and restores byte-identical settings', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + permissions: { allow: [] }, + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + // ours are present now + const mid = inspectInstall(settings) + expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(mid.statusline).toBe(true) + + await runAction(buildUninstall(settings).plan!, actionsDir) + expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical + const after = inspectInstall(settings) + expect(after.hooks).toEqual([]) + expect(after.statusline).toBe(false) + }) + + it('preserves a user hook that shares the PreToolUse array', async () => { + const { settings, actionsDir } = await makeRoot() + await writeFile(settings, canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + })) + await runAction(buildInstall(settings).plan!, actionsDir) + await runAction(buildUninstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toEqual(['my-own-hook.sh']) + }) + + it('reports nothing to do on a settings file without our hooks', async () => { + const { settings } = await makeRoot() + await writeFile(settings, canonical({ hooks: {} })) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + expect(built.notes.join(' ')).toContain('no codeburn guard hooks') + }) + + it('reports nothing to do when the settings file is absent', async () => { + const { settings } = await makeRoot() + await rm(settings, { force: true }) + expect(existsSync(settings)).toBe(false) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + }) +}) diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts new file mode 100644 index 0000000..2b58228 --- /dev/null +++ b/tests/optimize-apply.test.ts @@ -0,0 +1,655 @@ +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 { PassThrough, Writable } from 'node:stream' + +import { planFor, planFindings, type PlanContext } from '../src/act/plans.js' +import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js' +import { runAction } from '../src/act/apply.js' +import { undoAction } from '../src/act/undo.js' +import { readRecords, shortId } 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], removeProjects: [] }], + }) + 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) + }) +}) + +describe('unused-mcp plan', () => { + it('builds a remove-everywhere plan, including other projects[*] entries', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { u: { command: 'u' } }, + projects: { '/some/other': { mcpServers: { u: { command: 'u' }, keepme: {} } } }, + }, null, 2) + '\n') + + const finding = makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['u'] }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + expect(plan!.kind).toBe('mcp-remove') + + await runAction(plan!, fx.actionsDir) + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({}) + expect(after.projects['/some/other'].mcpServers).toEqual({ keepme: {} }) + }) +}) + +describe('BOM handling', () => { + it('parses a config file with a UTF-8 BOM', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, '' + JSON.stringify({ mcpServers: { b: {} }, keep: 1 }, null, 2) + '\n') + + const { plan, notes } = planFindings( + [makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['b'] })], + { homeDir: fx.home, cwd: fx.project }, + )[0]! + expect(notes).toEqual([]) + expect(plan).not.toBeNull() + const written = JSON.parse(String(plan!.changes[0]!.content)) + expect(written).toEqual({ mcpServers: {}, keep: 1 }) + }) +}) + +// --------------------------------------------------------------------------- +// runOptimizeApply end-to-end (injected stdio, crafted findings, fixture home) +// --------------------------------------------------------------------------- + +const ANSI = /\[[0-9;]*m/g + +type Io = { + input: PassThrough + output: Writable + errorOutput: Writable + stdout(): string + stderr(): string +} + +function makeIo(answer?: string): Io { + const input = new PassThrough() + input.end(answer ?? '') + const outChunks: Buffer[] = [] + const errChunks: Buffer[] = [] + const output = new Writable({ write(c, _e, cb) { outChunks.push(Buffer.from(c)); cb() } }) + const errorOutput = new Writable({ write(c, _e, cb) { errChunks.push(Buffer.from(c)); cb() } }) + return { + input, + output, + errorOutput, + stdout: () => Buffer.concat(outChunks).toString('utf-8').replace(ANSI, ''), + stderr: () => Buffer.concat(errChunks).toString('utf-8').replace(ANSI, ''), + } +} + +function applyOpts(fx: Fixture, io: Io, extra: Partial & { findings: WasteFinding[] }): ApplyOptions { + const ctx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' } + return { + ctx, + actionsDir: fx.actionsDir, + input: io.input, + output: io.output, + errorOutput: io.errorOutput, + ...extra, + } +} + +// One mcp server, one skill, one shell cap: three appliable findings in a +// stable 1/2/3 order for the picker tests. +async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFinding[] }> { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { a: { command: 'a' } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x') + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['a'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }), + makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }), + ] + return { fx, findings } +} + +describe('runOptimizeApply end-to-end', () => { + it('--yes applies every plan and prints journal short ids with the undo hint', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(3) + const out = io.stdout() + for (const rec of records) { + expect(out).toContain(`Applied ${shortId(rec.id)}`) + expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`) + } + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({}) + expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true) + expect(existsSync(join(fx.home, '.zshrc'))).toBe(true) + }) + + it('interactive pick "2" applies only the second plan', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('2\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.kind).toBe('archive-skill') + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } }) + expect(existsSync(join(fx.home, '.zshrc'))).toBe(false) + }) + + it('interactive pick "1,3" applies the first and third plans', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('1,3\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + const records = await readRecords(fx.actionsDir) + expect(records.map(r => r.kind).sort()).toEqual(['mcp-remove', 'shell-config']) + expect(existsSync(join(fx.home, '.claude', 'skills', 'foo'))).toBe(true) + }) + + it('a garbage answer applies nothing and prints the empty outcome', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('wat\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + expect(io.stdout()).toContain('Nothing applied.') + }) + + it('EOF at the prompt prints "Nothing applied." and leaves the exit code untouched', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + const prevExit = process.exitCode + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + expect(process.exitCode).toBe(prevExit) + expect(io.stdout()).toContain('Nothing applied.') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('--only restricts the applied set', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'unused-skills' })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.kind).toBe('archive-skill') + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } }) + }) + + it('--only with an unknown or not-appliable id errors with the valid ids and exit code 2', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + const prevExit = process.exitCode + try { + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'read-edit-ratio' })) + + expect(process.exitCode).toBe(2) + const err = io.stderr() + expect(err).toContain('read-edit-ratio') + expect(err).toContain('Appliable ids for this run:') + expect(err).toContain('mcp-low-coverage') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + expect(io.stdout()).not.toContain('No appliable config-class fixes') + } finally { + process.exitCode = prevExit + } + }) + + it('--yes skips claude-md plans with a reason unless explicitly selected via --only', async () => { + const { fx, findings } = await threeFindingFixture() + const claudeMdFinding = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first.' }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [claudeMdFinding, ...findings], yes: true })) + + expect(io.stdout()).toContain('Skipped read-edit-ratio: CLAUDE.md edits are not applied with --yes') + expect(existsSync(join(fx.project, 'CLAUDE.md'))).toBe(false) + expect((await readRecords(fx.actionsDir)).map(r => r.kind)).not.toContain('claude-md-rule') + + const fx2 = await makeFixture() + const io2 = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx2, io2, { findings: [claudeMdFinding], yes: true, only: 'read-edit-ratio' })) + + expect((await readRecords(fx2.actionsDir)).map(r => r.kind)).toEqual(['claude-md-rule']) + expect(await readFile(join(fx2.project, 'CLAUDE.md'), 'utf-8')).toContain('') + }) + + it('project-scope leaves unrelated projects[*] entries untouched and previews the cold removals', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const serverValue = { command: 'srv' } + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { srv: serverValue }, + projects: { + '/cold/one': { mcpServers: { srv: serverValue } }, + '/unrelated/proj': { mcpServers: { srv: serverValue, keepme: {} } }, + }, + }, null, 2) + '\n') + const keeper = join(fx.root, 'keeper') + await mkdir(keeper, { recursive: true }) + + const finding = makeFinding('mcp-project-scope', CMD_FIX, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: ['/cold/one'] }], + }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + expect(io.stdout()).toContain('(removes srv from 1 project entry: /cold/one)') + + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({}) + expect(after.projects['/cold/one'].mcpServers).toEqual({}) + expect(after.projects['/unrelated/proj'].mcpServers).toEqual({ srv: serverValue, keepme: {} }) + expect(JSON.parse(await readFile(join(keeper, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ srv: serverValue }) + }) + + it('surfaces parse-error notes when every plan resolves to null', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), 'not json{{{') + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + const out = io.stdout() + expect(out).toContain('No appliable config-class fixes') + expect(out).toContain('could not parse') + expect(out).toContain('.claude.json') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('renders notes under manual findings alongside appliable ones', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), 'not json{{{') + await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x') + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }), + ] + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, dryRun: true })) + + const out = io.stdout() + expect(out).toContain('[mcp-low-coverage] manual') + expect(out).toContain('could not parse') + }) +}) + +describe('stale-plan detection', () => { + it('rejects when the target changed after the plan was built, leaving nothing behind', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n') + const plan = planFor( + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan).not.toBeNull() + + const interim = JSON.stringify({ mcpServers: { s: {}, addedMeanwhile: {} } }, null, 2) + '\n' + await writeFile(claudeJson, interim) + + await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built; re-run codeburn optimize --apply/) + expect(await readFile(claudeJson, 'utf-8')).toBe(interim) + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + const backups = await readdir(join(fx.actionsDir, 'backups')).catch(() => []) + expect(backups).toEqual([]) + }) + + it('rejects a plan expecting an absent file when the file appeared', async () => { + const fx = await makeFixture() + const claudeMd = join(fx.project, 'CLAUDE.md') + const plan = planFor( + makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan!.changes[0]).toMatchObject({ op: 'create', expectedHash: null }) + + await writeFile(claudeMd, '# appeared meanwhile\n') + + await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built/) + expect(await readFile(claudeMd, 'utf-8')).toBe('# appeared meanwhile\n') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('applies when the target still matches the expected hash', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n') + const plan = planFor( + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan!.changes[0]!.op === 'move' ? undefined : plan!.changes[0]!.expectedHash).toMatch(/^[0-9a-f]{64}$/) + + const rec = await runAction(plan!, fx.actionsDir) + expect(rec.status).toBe('applied') + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + }) + + it('a change without expectedHash skips validation (framework back-compat)', async () => { + const fx = await makeFixture() + const p = join(fx.home, 'free.txt') + await writeFile(p, 'anything at all') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'no expected hash', + changes: [{ op: 'edit', path: p, content: 'overwritten' }], + }, fx.actionsDir) + expect(rec.status).toBe('applied') + expect(await readFile(p, 'utf-8')).toBe('overwritten') + }) +})