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:
AgentSeal 2026-07-03 10:41:29 +02:00
parent 2eec2fdc31
commit 0dd54366b7
5 changed files with 906 additions and 2 deletions

125
src/act/optimize-apply.ts Normal file
View 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
View 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: [],
}
}

View file

@ -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

View file

@ -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,

View 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)
})
})