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