feat(act): realized savings measurement (codeburn act report) (#606)

Capture a trailing-14-day before-baseline when a fix is applied and
re-measure it against the post-apply window so optimize can show realized
numbers next to estimates.

- ActionBaseline (windowDays, capturedAt, estimatedTokens, sessions, metrics)
  persisted by runAction; captured in the optimize --apply flow and at guard
  install time.
- codeburn act report [--json]: applied, not-undone actions older than 3 days,
  re-running the detectors over apply-date-to-now (capped 30 days). Per-kind
  realized deltas: MCP/archive tokens-per-session times saved sessions with
  reverted-by-user detection; read-edit deficit reduction; guard yield split
  labeled correlation. Bash cap is marked not measurable (result sizes are not
  retained). Low confidence under 20 post-window sessions or past a 2x volume
  shift. Realized numbers rounded down, estimate kept visible.
- optimize gains one header line only when a measured action exists, and
  appends "(previously applied <date>, re-flagged)" to re-triggered findings.
  No change for users with no applied actions.

Reuses scanAndDetect helpers over a date-bounded range; exports the token
constants and read/edit tool sets rather than duplicating the math.
This commit is contained in:
AgentSeal 2026-07-03 13:04:37 +02:00
parent be2470d0e5
commit ad471f3d8c
8 changed files with 932 additions and 16 deletions

View file

@ -2,6 +2,7 @@ import type { Command } from 'commander'
import { renderTable } from '../text-table.js'
import { defaultActionsDir, readRecords, shortId } from './journal.js'
import { DriftError, undoAction } from './undo.js'
import { buildActReportJson, computeActReport, renderActReport } from './report.js'
function formatWhen(at: string): string {
return at.replace('T', ' ').slice(0, 16)
@ -63,4 +64,22 @@ export function registerActCommands(program: Command): void {
process.exitCode = 1
}
})
act
.command('report')
.description('Realized vs estimated savings for applied actions older than 3 days')
.option('--json', 'Output the realized report as JSON')
.action(async (opts: { json?: boolean }) => {
try {
const report = await computeActReport()
if (opts.json) {
console.log(JSON.stringify(buildActReportJson(report), null, 2))
return
}
console.log(renderActReport(report))
} catch (err) {
console.error(err instanceof Error ? err.message : String(err))
process.exitCode = 1
}
})
}

View file

@ -162,6 +162,15 @@ export async function runOptimizeApply(
return
}
// Stamp a trailing-14-day before-baseline onto each plan so runAction
// persists it and `act report` can measure realized savings later. Best
// effort: a scan failure leaves the baseline absent (reported "not
// measurable"), never blocking the apply.
try {
const { captureBaselinesForPlans } = await import('./report.js')
await captureBaselinesForPlans(selected)
} catch { /* baseline is optional; apply proceeds without it */ }
print()
for (const fp of selected) {
try {

537
src/act/report.ts Normal file
View file

@ -0,0 +1,537 @@
import { existsSync } from 'fs'
import type { DateRange, ProjectSummary, SessionSummary } from '../types.js'
import type { ActionBaseline, ActionKind, ActionRecord } from './types.js'
import type { FindingPlan } from './plans.js'
import {
AVG_TOKENS_PER_READ,
HEALTHY_READ_EDIT_RATIO,
TOKENS_PER_MCP_TOOL,
TOOLS_PER_MCP_SERVER,
TOKENS_PER_SKILL_DEF,
TOKENS_PER_AGENT_DEF,
TOKENS_PER_COMMAND_DEF,
READ_TOOL_NAMES,
EDIT_TOOL_NAMES,
aggregateMcpCoverage,
computeInputCostRate,
type McpServerCoverage,
type WasteFinding,
} from '../optimize.js'
import { parseAllSessions } from '../parser.js'
import { computeYield, type YieldSummary } from '../yield.js'
import { defaultActionsDir, readRecords } from './journal.js'
import { renderTable } from '../text-table.js'
import { formatTokens } from '../format.js'
import { formatCost } from '../currency.js'
const DAY_MS = 24 * 60 * 60 * 1000
const WINDOW_CAP_DAYS = 30
const BASELINE_WINDOW_DAYS = 14
const REPORT_MIN_AGE_DAYS = 3
const MIN_POST_WINDOW_SESSIONS = 20
const VOLUME_SHIFT_FACTOR = 2
// Encode the epic's honest-accounting rules where they are seen: each kind
// measures only its own metric, guard is correlation, and realized figures are
// rounded down with the estimate kept visible.
const HONEST_FOOTER =
'Each fix measures only its own metric; effects are never attributed across signals. '
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down; the estimate stays visible.'
const MCP_KINDS = new Set<ActionKind>(['mcp-remove', 'mcp-project-scope'])
const ARCHIVE_DEF_TOKENS: Partial<Record<ActionKind, number>> = {
'archive-skill': TOKENS_PER_SKILL_DEF,
'archive-agent': TOKENS_PER_AGENT_DEF,
'archive-command': TOKENS_PER_COMMAND_DEF,
}
export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable'
export type ActReportRow = {
id: string
appliedAt: string
date: string
kind: ActionKind
description: string
estimatedTokens: number
realizedTokens: number
status: RealizedStatus
confidence: 'low' | 'normal'
note: string
// guard-install only: yield split then vs now, labeled correlation.
correlation?: {
abandonedPctThen: number
abandonedPctNow: number
avgSessionCostThenUSD: number
avgSessionCostNowUSD: number
}
}
export type ActReport = {
generatedAt: string
windowCapDays: number
costRate: number
rows: ActReportRow[]
totalRealizedTokens: number
totalRealizedCostUSD: number
measuredCount: number
activeCount: number
observedDays: number
// findingId -> earliest apply date of an active applied action; drives the
// optimize "(previously applied ..., re-flagged)" title suffix.
appliedByFinding: Record<string, string>
}
export type ActReportOptions = {
actionsDir?: string
now?: Date
cwd?: string
loadProjects?: (range: DateRange) => Promise<ProjectSummary[]>
computeYield?: (range: DateRange) => Promise<YieldSummary>
}
// ---------------------------------------------------------------------------
// Shared measurement helpers
// ---------------------------------------------------------------------------
function ageDays(iso: string, now: Date): number {
return (now.getTime() - new Date(iso).getTime()) / DAY_MS
}
function allSessions(projects: ProjectSummary[]): SessionSummary[] {
return projects.flatMap(p => p.sessions)
}
function sessionsInWindow(projects: ProjectSummary[], start: Date, end: Date): SessionSummary[] {
const out: SessionSummary[] = []
for (const p of projects) {
for (const s of p.sessions) {
if (!s.firstTimestamp) continue
const t = new Date(s.firstTimestamp).getTime()
if (t >= start.getTime() && t <= end.getTime()) out.push(s)
}
}
return out
}
function countToolCalls(sessions: SessionSummary[], names: ReadonlySet<string>): number {
let n = 0
for (const s of sessions) {
for (const [tool, data] of Object.entries(s.toolBreakdown)) {
if (names.has(tool)) n += data.calls
}
}
return n
}
function countBashCalls(sessions: SessionSummary[]): number {
let n = 0
for (const s of sessions) {
for (const data of Object.values(s.bashBreakdown)) n += data.calls
}
return n
}
function sessionLoadsAny(s: SessionSummary, servers: string[]): boolean {
for (const fqn of s.mcpInventory ?? []) {
const seg = fqn.split('__')[1]
if (seg && servers.includes(seg)) return true
}
for (const server of Object.keys(s.mcpBreakdown)) {
if (servers.includes(server)) return true
}
return false
}
function countSessionsLoading(projects: ProjectSummary[], servers: string[]): number {
return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length
}
// A kind whose realized effect is a token saving (everything except guard,
// which is a dollars/yield correlation, and out-of-scope kinds).
function isTokenKind(kind: ActionKind): boolean {
return kind !== 'guard-install' && kind !== 'guard-uninstall' && kind !== 'model-default'
}
function confidenceFor(afterSessions: number, baseline: ActionBaseline, afterStart: Date, now: Date): 'low' | 'normal' {
if (afterSessions < MIN_POST_WINDOW_SESSIONS) return 'low'
if (baseline.sessions > 0 && baseline.windowDays > 0) {
const afterDays = Math.max((now.getTime() - afterStart.getTime()) / DAY_MS, 1)
const shift = (afterSessions / afterDays) / (baseline.sessions / baseline.windowDays)
if (shift > VOLUME_SHIFT_FACTOR || shift < 1 / VOLUME_SHIFT_FACTOR) return 'low'
}
return 'normal'
}
// ---------------------------------------------------------------------------
// Per-kind realized deltas
// ---------------------------------------------------------------------------
function mcpRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
): ActReportRow {
const servers = Object.keys(baseline.metrics)
const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0)
if (servers.length === 0 || perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' }
const stillLoading = sessions.filter(s => sessionLoadsAny(s, servers)).length
const confidence = confidenceFor(sessions.length, baseline, afterStart, now)
if (rec.kind === 'mcp-remove' && stillLoading > 0) {
return {
...base,
status: 'reverted',
confidence,
note: `reverted by user: ${servers.join(', ')} loaded again in ${stillLoading} post-apply session${stillLoading === 1 ? '' : 's'}`,
}
}
const savedSessions = Math.max(0, sessions.length - stillLoading)
return { ...base, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence }
}
function archiveRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
): ActReportRow {
const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0)
if (perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' }
const confidence = confidenceFor(sessions.length, baseline, afterStart, now)
const restored = rec.changes.some(c => c.op === 'move' && existsSync(c.path))
if (restored) {
return { ...base, status: 'reverted', confidence, note: 'reverted by user: an archived item was moved back into place' }
}
return { ...base, status: 'measured', realizedTokens: Math.floor(perSessionTokens * sessions.length), confidence }
}
function readEditRow(
base: ActReportRow, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
): ActReportRow {
const editsNow = countToolCalls(sessions, EDIT_TOOL_NAMES)
const readsNow = countToolCalls(sessions, READ_TOOL_NAMES)
const editsThen = baseline.metrics['edits'] ?? 0
const readsThen = baseline.metrics['reads'] ?? 0
if (editsThen <= 0 || editsNow <= 0) return { ...base, note: 'not measurable: not enough edit activity to compare' }
const ratioThen = readsThen / editsThen
const ratioNow = readsNow / editsNow
// Detector estimate math: reads short of HEALTHY_READ_EDIT_RATIO per edit are
// the retry-prone deficit. Credit only the reduction in that deficit, scaled
// by current edits; a worsened ratio claims nothing.
const deficitThen = Math.max(HEALTHY_READ_EDIT_RATIO - ratioThen, 0)
const deficitNow = Math.max(HEALTHY_READ_EDIT_RATIO - ratioNow, 0)
const realized = Math.floor(Math.max(0, deficitThen - deficitNow) * editsNow * AVG_TOKENS_PER_READ)
return {
...base,
status: 'measured',
realizedTokens: realized,
confidence: confidenceFor(sessions.length, baseline, afterStart, now),
note: `read:edit ${ratioThen.toFixed(1)}:1 -> ${ratioNow.toFixed(1)}:1`,
}
}
async function guardRow(
base: ActReportRow, afterStart: Date, now: Date,
baseline: ActionBaseline, opts: ActReportOptions,
): Promise<ActReportRow> {
const abandonedThen = baseline.metrics['abandonedPct']
const avgThen = baseline.metrics['avgSessionCostUSD']
if (abandonedThen === undefined || avgThen === undefined) {
return { ...base, note: 'not measurable: no yield baseline captured at install time' }
}
const yieldFn = opts.computeYield ?? ((range: DateRange) => computeYield(range, opts.cwd ?? process.cwd()))
let summary: YieldSummary
try {
summary = await yieldFn({ start: afterStart, end: now })
} catch {
return { ...base, note: 'not measurable: yield could not be computed for the post-apply window' }
}
const abandonedNow = summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0
const avgNow = summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0
return {
...base,
status: 'measured',
confidence: summary.total.sessions < MIN_POST_WINDOW_SESSIONS ? 'low' : 'normal',
note: 'correlation, not attribution',
correlation: {
abandonedPctThen: abandonedThen,
abandonedPctNow: abandonedNow,
avgSessionCostThenUSD: avgThen,
avgSessionCostNowUSD: avgNow,
},
}
}
async function computeRow(rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date, opts: ActReportOptions): Promise<ActReportRow> {
const base: ActReportRow = {
id: rec.id,
appliedAt: rec.at,
date: rec.at.slice(0, 10),
kind: rec.kind,
description: rec.description,
estimatedTokens: rec.baseline?.estimatedTokens ?? 0,
realizedTokens: 0,
status: 'not-measurable',
confidence: 'normal',
note: '',
}
const baseline = rec.baseline
if (!baseline) return { ...base, note: 'not measurable: no baseline captured at apply time' }
if (MCP_KINDS.has(rec.kind)) return mcpRow(base, rec, sessions, baseline, afterStart, now)
if (rec.kind in ARCHIVE_DEF_TOKENS) return archiveRow(base, rec, sessions, baseline, afterStart, now)
if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now)
if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' }
if (rec.kind === 'guard-install') return guardRow(base, afterStart, now, baseline, opts)
return { ...base, note: 'not measurable: kind is not tracked by act report' }
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
export async function computeActReport(opts: ActReportOptions = {}): Promise<ActReport> {
const now = opts.now ?? new Date()
const records = await readRecords(opts.actionsDir ?? defaultActionsDir())
const active = records.filter(r => r.status === 'applied')
const appliedByFinding: Record<string, string> = {}
for (const r of active) {
if (!r.findingId) continue
const date = r.at.slice(0, 10)
const prev = appliedByFinding[r.findingId]
if (!prev || date < prev) appliedByFinding[r.findingId] = date
}
const empty: ActReport = {
generatedAt: now.toISOString(),
windowCapDays: WINDOW_CAP_DAYS,
costRate: 0,
rows: [],
totalRealizedTokens: 0,
totalRealizedCostUSD: 0,
measuredCount: 0,
activeCount: active.length,
observedDays: 0,
appliedByFinding,
}
const eligible = active.filter(r => ageDays(r.at, now) > REPORT_MIN_AGE_DAYS)
if (eligible.length === 0) return empty
const windowStart = new Date(now.getTime() - WINDOW_CAP_DAYS * DAY_MS)
const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude'))
const projects = await loadProjects({ start: windowStart, end: now })
const costRate = computeInputCostRate(projects)
const rows: ActReportRow[] = []
for (const rec of eligible) {
const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime()))
rows.push(await computeRow(rec, sessionsInWindow(projects, afterStart, now), afterStart, now, opts))
}
const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind))
const totalRealizedTokens = measuredRows.reduce((s, r) => s + r.realizedTokens, 0)
const observedDays = Math.min(
WINDOW_CAP_DAYS,
measuredRows.reduce((mx, r) => Math.max(mx, Math.ceil(ageDays(r.appliedAt, now))), 0),
)
return {
generatedAt: now.toISOString(),
windowCapDays: WINDOW_CAP_DAYS,
costRate,
rows,
totalRealizedTokens,
totalRealizedCostUSD: totalRealizedTokens * costRate,
measuredCount: measuredRows.length,
activeCount: active.length,
observedDays,
appliedByFinding,
}
}
export function buildOptimizeAppliedHeader(report: ActReport): string | null {
if (report.measuredCount === 0) return null
const cost = report.costRate > 0 ? ` (~${formatCost(report.totalRealizedCostUSD)})` : ''
const days = report.observedDays
return `Applied fixes: ${report.activeCount} active, realized ~${formatTokens(report.totalRealizedTokens)} tokens${cost} over ${days} day${days === 1 ? '' : 's'}. Details: codeburn act report`
}
function realizedCell(r: ActReportRow): string {
if (r.status === 'reverted') return 'reverted'
if (r.status === 'not-measurable') return 'not measurable'
if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)`
return formatTokens(r.realizedTokens)
}
export function renderActReport(report: ActReport): string {
if (report.rows.length === 0) {
const lines = ['', ' No applied actions to measure yet.']
if (report.activeCount > 0) {
lines.push(` ${report.activeCount} action${report.activeCount === 1 ? '' : 's'} applied; measurement starts after ${REPORT_MIN_AGE_DAYS} days.`)
} else {
lines.push(' Apply fixes with codeburn optimize --apply, then check back after a few days.')
}
lines.push('')
return lines.join('\n')
}
const rows = report.rows.map(r => [
r.date,
r.description,
r.estimatedTokens > 0 ? formatTokens(r.estimatedTokens) : '-',
realizedCell(r),
r.status === 'measured' && isTokenKind(r.kind) ? r.confidence : '-',
])
const totalCost = report.costRate > 0 ? ` (~${formatCost(report.totalRealizedCostUSD)})` : ''
rows.push(['', 'Total realized', '', `${formatTokens(report.totalRealizedTokens)}${totalCost}`, ''])
const table = renderTable(
[{ header: 'Applied' }, { header: 'Action' }, { header: 'Estimated', right: true }, { header: 'Realized', right: true }, { header: 'Confidence' }],
rows,
{ boldRows: new Set([rows.length - 1]) },
)
const details: string[] = []
for (const r of report.rows) {
if (r.status === 'measured' && isTokenKind(r.kind)) continue
if (r.note) details.push(` ${r.date} ${r.kind}: ${r.note}`)
if (r.correlation) {
details.push(` avg session cost ${formatCost(r.correlation.avgSessionCostThenUSD)} -> ${formatCost(r.correlation.avgSessionCostNowUSD)}`)
}
}
return ['', table, ...(details.length > 0 ? ['', ...details] : []), '', ' ' + HONEST_FOOTER, ''].join('\n')
}
export function buildActReportJson(report: ActReport): unknown {
return {
generatedAt: report.generatedAt,
windowCapDays: report.windowCapDays,
actions: report.rows.map(r => {
const tokenMeasured = r.status === 'measured' && isTokenKind(r.kind)
return {
id: r.id,
date: r.date,
kind: r.kind,
description: r.description,
estimatedTokens: r.estimatedTokens,
realizedTokens: tokenMeasured ? r.realizedTokens : null,
status: r.status,
confidence: tokenMeasured ? r.confidence : null,
note: r.note,
...(r.correlation ? { correlation: r.correlation } : {}),
}
}),
totals: {
realizedTokens: report.totalRealizedTokens,
realizedCostUSD: report.totalRealizedCostUSD,
measuredActions: report.measuredCount,
activeActions: report.activeCount,
observedDays: report.observedDays,
},
footer: HONEST_FOOTER,
}
}
// ---------------------------------------------------------------------------
// Baseline capture (apply time)
// ---------------------------------------------------------------------------
type CaptureCtx = {
projects: ProjectSummary[]
coverage: McpServerCoverage[]
windowDays: number
now: Date
}
function mcpServersFromApply(finding: WasteFinding): string[] {
if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers
if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server)
return []
}
function needsConfigBaseline(kind: ActionKind): boolean {
return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
}
export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
const common = {
windowDays: ctx.windowDays,
capturedAt: ctx.now.toISOString(),
estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)),
}
if (MCP_KINDS.has(kind)) {
const servers = mcpServersFromApply(finding)
if (servers.length === 0) return undefined
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
const metrics: Record<string, number> = {}
for (const server of servers) {
const cov = covByServer.get(server)
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
metrics[server] = tools * TOKENS_PER_MCP_TOOL
}
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}
const defTokens = ARCHIVE_DEF_TOKENS[kind]
if (defTokens !== undefined) {
const names = finding.apply?.kind === 'archive' ? finding.apply.names : []
if (names.length === 0) return undefined
const metrics: Record<string, number> = {}
for (const name of names) metrics[name] = defTokens
return { ...common, sessions: allSessions(ctx.projects).length, metrics }
}
const sessions = allSessions(ctx.projects)
if (kind === 'claude-md-rule') {
return { ...common, sessions: sessions.length, metrics: { reads: countToolCalls(sessions, READ_TOOL_NAMES), edits: countToolCalls(sessions, EDIT_TOOL_NAMES) } }
}
if (kind === 'shell-config') {
return { ...common, sessions: sessions.length, metrics: { calls: countBashCalls(sessions) } }
}
return undefined
}
// Scan the trailing 14 days once and stamp a baseline onto every appliable
// plan that carries one, so runAction persists it for `act report` to diff.
export async function captureBaselinesForPlans(
plans: FindingPlan[],
opts: { now?: Date; loadProjects?: (range: DateRange) => Promise<ProjectSummary[]> } = {},
): Promise<void> {
const applicable = plans.filter(fp => fp.plan && needsConfigBaseline(fp.plan.kind))
if (applicable.length === 0) return
const now = opts.now ?? new Date()
const start = new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS)
const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude'))
const projects = await loadProjects({ start, end: now })
const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now }
for (const fp of applicable) {
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx)
if (baseline) fp.plan!.baseline = baseline
}
}
export async function captureGuardBaseline(
opts: { now?: Date; cwd?: string; computeYield?: (range: DateRange) => Promise<YieldSummary> } = {},
): Promise<ActionBaseline | undefined> {
const now = opts.now ?? new Date()
const range = { start: new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS), end: now }
const yieldFn = opts.computeYield ?? ((r: DateRange) => computeYield(r, opts.cwd ?? process.cwd()))
let summary: YieldSummary
try {
summary = await yieldFn(range)
} catch {
return undefined
}
return {
windowDays: BASELINE_WINDOW_DAYS,
capturedAt: now.toISOString(),
estimatedTokens: 0,
sessions: summary.total.sessions,
metrics: {
abandonedPct: summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0,
avgSessionCostUSD: summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0,
},
}
}

View file

@ -14,6 +14,27 @@ export type FileChange = {
afterHash: string // sha256 of the post-apply bytes, checked for drift on undo
}
// Before/after measurement captured when an action is applied, diffed against
// the post-apply window by `act report`. `metrics` holds the kind-specific
// numbers:
// mcp-remove / mcp-project-scope: server name -> schema tokens per session
// archive-skill|agent|command: item name -> definition tokens per session
// claude-md-rule (read/edit rule): { reads, edits }
// shell-config (bash cap): { calls }
// guard-install: { abandonedPct, avgSessionCostUSD }
// estimatedTokens is the finding's estimate at apply time (0 for guard, which
// is a correlation signal, not a token estimate). sessions is the affected-scope
// session count over the window, kept out of `metrics` so it can never collide
// with a server literally named "sessions"; it feeds only the volume-shift
// confidence check.
export type ActionBaseline = {
windowDays: number
capturedAt: string
estimatedTokens: number
sessions: number
metrics: Record<string, number>
}
export type ActionRecord = {
id: string // crypto.randomUUID()
at: string // ISO timestamp
@ -23,7 +44,7 @@ export type ActionRecord = {
changes: FileChange[]
status: 'applied' | 'undone'
undoneAt?: string
baseline?: Record<string, number>
baseline?: ActionBaseline
}
// expectedHash: sha256 of the raw on-disk bytes the plan's content was
@ -41,5 +62,5 @@ export type ActionPlan = {
description: string
findingId?: string | null
changes: PlannedChange[]
baseline?: Record<string, number>
baseline?: ActionBaseline
}

View file

@ -41,6 +41,14 @@ async function doInstall(scope: Scope, statusline: boolean): Promise<void> {
const built = buildInstall(path, { statusline })
for (const note of built.notes) console.log(chalk.yellow(` ! ${note}`))
if (built.plan) {
// Capture a trailing-14-day yield baseline so `act report` can correlate
// the guard install against later yield. Best effort; a failure just
// leaves the guard row "not measurable".
try {
const { captureGuardBaseline } = await import('../act/report.js')
const baseline = await captureGuardBaseline({ cwd: process.cwd() })
if (baseline) built.plan.baseline = baseline
} catch { /* baseline is optional */ }
const record = await runAction(built.plan)
console.log(` Installed ${chalk.bold(shortId(record.id))} ${built.plan.description}`)
console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))

View file

@ -1350,7 +1350,20 @@ program
return
}
assertFormat(format, ['text', 'json'], 'optimize')
await runOptimize(projects, label, range, { format })
if (format === 'text') {
// Surface realized savings from applied actions. computeActReport returns
// fast without scanning when the journal has no eligible applied actions,
// so users who never opted in see identical output.
const { computeActReport, buildOptimizeAppliedHeader } = await import('./act/report.js')
const applied = await computeActReport()
await runOptimize(projects, label, range, {
format,
appliedHeader: buildOptimizeAppliedHeader(applied) ?? undefined,
previouslyApplied: applied.appliedByFinding,
})
} else {
await runOptimize(projects, label, range, { format })
}
})
program

View file

@ -26,12 +26,12 @@ const RED = '#F55B5B'
// Token estimation constants
// ============================================================================
const AVG_TOKENS_PER_READ = 600
const TOKENS_PER_MCP_TOOL = 400
const TOOLS_PER_MCP_SERVER = 5
const TOKENS_PER_AGENT_DEF = 80
const TOKENS_PER_SKILL_DEF = 80
const TOKENS_PER_COMMAND_DEF = 60
export const AVG_TOKENS_PER_READ = 600
export const TOKENS_PER_MCP_TOOL = 400
export const TOOLS_PER_MCP_SERVER = 5
export const TOKENS_PER_AGENT_DEF = 80
export const TOKENS_PER_SKILL_DEF = 80
export const TOKENS_PER_COMMAND_DEF = 60
const CLAUDEMD_TOKENS_PER_LINE = 13
const BASH_TOKENS_PER_CHAR = 0.25
@ -48,7 +48,7 @@ const MIN_DUPLICATE_READS_TO_FLAG = 5
const DUPLICATE_READS_HIGH_THRESHOLD = 30
const DUPLICATE_READS_MEDIUM_THRESHOLD = 10
const MIN_EDITS_FOR_RATIO = 10
const HEALTHY_READ_EDIT_RATIO = 4
export const HEALTHY_READ_EDIT_RATIO = 4
const LOW_RATIO_HIGH_THRESHOLD = 2
const LOW_RATIO_MEDIUM_THRESHOLD = 3
const MIN_API_CALLS_FOR_CACHE = 10
@ -1605,8 +1605,8 @@ export function detectBloatedClaudeMd(projectCwds: Set<string>): WasteFinding |
}
}
const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit'])
export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit'])
export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null {
let reads = 0
@ -2343,7 +2343,7 @@ function sessionTrend(
const INPUT_COST_RATIO = 0.7
const DEFAULT_COST_PER_TOKEN = 0
function computeInputCostRate(projects: ProjectSummary[]): number {
export function computeInputCostRate(projects: ProjectSummary[]): number {
const sessions = projects.flatMap(p => p.sessions)
const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
const totalTokens = sessions.reduce((s, sess) =>
@ -2526,6 +2526,8 @@ function renderOptimize(
callCount: number,
healthScore: number,
healthGrade: HealthGrade,
appliedHeader?: string,
previouslyApplied?: Record<string, string>,
): string {
const lines: string[] = []
lines.push('')
@ -2539,6 +2541,7 @@ function renderOptimize(
chalk.hex(GOLD)(formatCost(periodCost)),
`Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`,
].join(chalk.hex(DIM)(' ')))
if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader))
lines.push('')
if (findings.length === 0) {
@ -2561,7 +2564,10 @@ function renderOptimize(
lines.push('')
for (let i = 0; i < findings.length; i++) {
lines.push(...renderFinding(i + 1, findings[i], costRate))
const f = findings[i]!
const appliedOn = previouslyApplied?.[f.id]
const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f
lines.push(...renderFinding(i + 1, shown, costRate))
}
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
@ -2574,7 +2580,7 @@ export async function runOptimize(
projects: ProjectSummary[],
periodLabel: string,
dateRange?: DateRange,
opts: { format?: 'text' | 'json' } = {},
opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record<string, string> } = {},
): Promise<void> {
const format = opts.format ?? 'text'
if (projects.length === 0 && format === 'text') {
@ -2597,7 +2603,7 @@ export async function runOptimize(
return
}
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade)
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied)
console.log(output)
}

303
tests/act-report.test.ts Normal file
View file

@ -0,0 +1,303 @@
import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { journalPath } from '../src/act/journal.js'
import {
buildActReportJson,
buildOptimizeAppliedHeader,
computeActReport,
renderActReport,
} from '../src/act/report.js'
import type { ActionRecord } from '../src/act/types.js'
import type { ProjectSummary } from '../src/types.js'
type Session = ProjectSummary['sessions'][number]
const roots: string[] = []
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
const NOW = new Date('2026-07-01T00:00:00Z')
function daysAgo(n: number): string {
return new Date(NOW.getTime() - n * 86_400_000).toISOString()
}
async function writeJournal(records: ActionRecord[]): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'codeburn-act-report-'))
roots.push(root)
const actionsDir = join(root, 'actions')
await mkdir(actionsDir, { recursive: true })
await writeFile(journalPath(actionsDir), records.map(r => JSON.stringify(r)).join('\n') + (records.length ? '\n' : ''))
return actionsDir
}
function makeSession(id: string, firstTimestamp: string, over: Partial<Session> = {}): Session {
return {
sessionId: id,
project: 'app',
firstTimestamp,
lastTimestamp: firstTimestamp,
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 1000,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as Session['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...over,
}
}
function projectOf(sessions: Session[]): ProjectSummary {
return {
project: 'app',
projectPath: '/tmp/app',
sessions,
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
totalSavingsUSD: 0,
totalApiCalls: sessions.length,
totalProxiedCostUSD: 0,
}
}
function sessionsAt(count: number, ts: string, over: Partial<Session> = {}): Session[] {
return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, ts, over))
}
function mcpRecord(over: Partial<ActionRecord> = {}): ActionRecord {
const at = daysAgo(10)
return {
id: 'a1',
at,
kind: 'mcp-remove',
findingId: 'unused-mcp',
description: 'Remove an MCP server from config',
changes: [],
status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 56_000, sessions: 28, metrics: { 'brave-search': 2000 } },
...over,
}
}
const load = (projects: ProjectSummary[]) => async () => projects
describe('mcp realized delta', () => {
it('multiplies baseline tokens-per-session by post-window sessions (exact)', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows).toHaveLength(1)
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(40_000) // 2000 tokens/session * 20 sessions
expect(row.estimatedTokens).toBe(56_000)
expect(row.confidence).toBe('normal')
expect(report.totalRealizedTokens).toBe(40_000)
})
it('reports "reverted" with zero savings when the server reappears in the window', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] })
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(back)]) })
const row = report.rows[0]!
expect(row.status).toBe('reverted')
expect(row.realizedTokens).toBe(0)
expect(row.note).toMatch(/reverted by user/)
expect(report.totalRealizedTokens).toBe(0)
})
})
describe('confidence markers', () => {
it('marks low when fewer than 20 post-window sessions', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(20_000) // 2000 * 10
expect(row.confidence).toBe('low')
})
it('marks low when volume shifts more than 2x versus baseline', async () => {
// 25 post-window sessions (>= 20, so not the count rule) over 10 days is
// 2.5/day against a 1/day baseline (14 sessions / 14 days) -> 2.5x shift.
const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 14, metrics: { 'brave-search': 2000 } } })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(25, daysAgo(5)))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.confidence).toBe('low')
})
it('stays normal when volume is comparable to baseline', async () => {
const actionsDir = await writeJournal([mcpRecord()]) // baseline 28/14 = 2/day
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) // 20/10 = 2/day
expect(report.rows[0]!.confidence).toBe('normal')
})
})
describe('eligibility', () => {
it('excludes undone actions and actions younger than 3 days', async () => {
const records = [
mcpRecord({ id: 'old', at: daysAgo(10) }),
mcpRecord({ id: 'young', at: daysAgo(1) }),
mcpRecord({ id: 'undone', at: daysAgo(20), status: 'undone' }),
]
const actionsDir = await writeJournal(records)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows).toHaveLength(1)
expect(report.rows[0]!.id).toBe('old')
expect(report.activeCount).toBe(2) // old + young are applied; undone is not
})
})
describe('read-edit realized delta', () => {
it('credits the reduction in the read deficit using the detector estimate math', async () => {
// Baseline ratio 1:1 (deficit 3 reads/edit). After window: 120 reads / 40
// edits = 3:1 (deficit 1). Credit (3 - 1) * 40 edits * 600 = 48000.
const at = daysAgo(10)
const rec: ActionRecord = {
id: 'r1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio',
description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 12_000, sessions: 28, metrics: { reads: 10, edits: 10 } },
}
const actionsDir = await writeJournal([rec])
const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 120 }, Edit: { calls: 40 } } })
const filler = sessionsAt(19, daysAgo(4))
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session, ...filler])]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(48_000)
expect(row.note).toMatch(/1\.0:1 -> 3\.0:1/)
})
})
describe('archive realized delta', () => {
it('measures per-item definition tokens times sessions and detects un-archive', async () => {
const at = daysAgo(10)
const kept = join(tmpdir(), 'codeburn-act-report-absent-skill-xyz') // absent -> not reverted
const base = { windowDays: 14, capturedAt: at, estimatedTokens: 160, sessions: 28, metrics: { 'skill-a': 80, 'skill-b': 80 } }
const rec: ActionRecord = {
id: 'ar1', at, kind: 'archive-skill', findingId: 'unused-skills',
description: 'Archive 2 unused skills', status: 'applied',
changes: [{ path: kept, backup: null, op: 'move', movedTo: kept + '.archived', afterHash: '' }],
baseline: base,
}
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows[0]!.status).toBe('measured')
expect(report.rows[0]!.realizedTokens).toBe(3200) // 160 tokens/session * 20
// Now the original path exists again -> reverted, zero savings.
const restoredPath = join(actionsDir, 'restored-skill')
await writeFile(restoredPath, 'x')
const rec2: ActionRecord = { ...rec, changes: [{ path: restoredPath, backup: null, op: 'move', movedTo: restoredPath + '.archived', afterHash: '' }] }
const dir2 = await writeJournal([rec2])
const report2 = await computeActReport({ actionsDir: dir2, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report2.rows[0]!.status).toBe('reverted')
expect(report2.rows[0]!.realizedTokens).toBe(0)
})
})
describe('unmeasured kinds', () => {
it('marks bash cap not measurable but keeps the estimate visible', async () => {
const at = daysAgo(10)
const rec: ActionRecord = {
id: 'b1', at, kind: 'shell-config', findingId: 'bash-output-cap',
description: 'Set the bash output cap', changes: [], status: 'applied',
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 3750, sessions: 28, metrics: { calls: 200 } },
}
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
expect(report.rows[0]!.status).toBe('not-measurable')
expect(report.rows[0]!.estimatedTokens).toBe(3750)
expect(report.measuredCount).toBe(0)
})
})
describe('optimize header', () => {
it('appears only when a measured token action exists', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const header = buildOptimizeAppliedHeader(report)
expect(header).toMatch(/^Applied fixes: 1 active, realized ~40\.0K tokens.*over 10 days\. Details: codeburn act report$/)
})
it('returns null and never scans when the journal has no eligible actions', async () => {
const emptyDir = await writeJournal([])
const report = await computeActReport({
actionsDir: emptyDir, now: NOW,
loadProjects: async () => { throw new Error('should not scan for an empty journal') },
})
expect(report.rows).toHaveLength(0)
expect(report.activeCount).toBe(0)
expect(buildOptimizeAppliedHeader(report)).toBeNull()
})
it('records the earliest apply date per finding for re-flagging', async () => {
const records = [
mcpRecord({ id: 'x1', at: daysAgo(9), findingId: 'unused-mcp' }),
mcpRecord({ id: 'x2', at: daysAgo(4), findingId: 'unused-mcp' }),
]
const actionsDir = await writeJournal(records)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(3)))]) })
expect(report.appliedByFinding['unused-mcp']).toBe(daysAgo(9).slice(0, 10))
})
})
describe('json + render shape', () => {
it('mirrors the rows and totals in --json', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const json = buildActReportJson(report) as {
actions: Array<Record<string, unknown>>
totals: Record<string, unknown>
footer: string
windowCapDays: number
}
expect(Array.isArray(json.actions)).toBe(true)
expect(json.actions[0]).toMatchObject({
kind: 'mcp-remove',
estimatedTokens: 56_000,
realizedTokens: 40_000,
status: 'measured',
confidence: 'normal',
})
expect(json.totals).toMatchObject({ realizedTokens: 40_000, measuredActions: 1, activeActions: 1 })
expect(json.windowCapDays).toBe(30)
expect(typeof json.footer).toBe('string')
expect(json.footer).toMatch(/correlation/)
})
it('renders an empty state without a table when nothing is measurable', async () => {
const emptyDir = await writeJournal([])
const report = await computeActReport({ actionsDir: emptyDir, now: NOW, loadProjects: async () => [] })
const out = renderActReport(report)
expect(out).toMatch(/No applied actions to measure yet/)
expect(out).not.toMatch(/Total realized/)
})
it('renders a table with a total row when measurements exist', async () => {
const actionsDir = await writeJournal([mcpRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) })
const out = renderActReport(report)
expect(out).toMatch(/Total realized/)
expect(out).toMatch(/40\.0K/)
expect(out).toMatch(/measures only its own metric/)
})
})