Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions node_modules
119 changes: 114 additions & 5 deletions src/act/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,27 @@ const HONEST_FOOTER =
'Estimates are scaled to the measured window for comparability; the at-apply estimate is kept in --json. '
+ 'MCP and archive realized figures are derived from per-session baselines times session counts, not independently measured. '
+ 'Each fix measures only its own metric; effects are never attributed across signals. '
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down.'
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down. '
+ 'Deferral rows exclude servers an MCP remove/scope row already measures.'

const MCP_KINDS = new Set<ActionKind>(['mcp-remove', 'mcp-project-scope'])
// defer-* re-enable native MCP tool deferral (part 2 of #614): the same
// prefix schema tokens mcp-remove eliminates, deferral moves out of the
// upfront prefix. Realized the same way — per-session schema tokens times the
// post-apply sessions that benefited — but "benefited" flips: instead of a
// server no longer loading, it is deferral having become active (the session
// now carries a deferred-tools inventory, the detector's own signal).
const DEFER_KINDS = new Set<ActionKind>(['defer-enable', 'defer-alwaysload', 'defer-threshold'])
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'
// 'pending' means the applied change has not taken effect in any post-apply
// session yet (e.g. deferral before a client restart) - distinct from
// 'reverted', which asserts the user undid it.
export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable' | 'pending'

export type ActReportRow = {
id: string
Expand Down Expand Up @@ -178,6 +189,28 @@ function countSessionsLoading(projects: ProjectSummary[], servers: string[]): nu
return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length
}

// Deferral is active in a session exactly when Claude Code emitted a
// deferred-tools inventory for it — the same signal the mcp-deferral-off
// detector uses (its absence, alongside MCP overhead, is what flags a gap).
function sessionHasDeferralActive(s: SessionSummary): boolean {
return (s.mcpInventory?.length ?? 0) > 0
}

// MCP servers observed loading in the window (via inventory or invocation).
// defer-enable / defer-threshold re-enable deferral for the whole MCP surface
// rather than a named set, so the affected servers are derived here.
function observedMcpServers(projects: ProjectSummary[]): string[] {
const servers = new Set<string>()
for (const s of allSessions(projects)) {
for (const fqn of s.mcpInventory ?? []) {
const seg = fqn.split('__')[1]
if (seg) servers.add(seg)
}
for (const server of Object.keys(s.mcpBreakdown)) servers.add(server)
}
return [...servers]
}

// 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 {
Expand Down Expand Up @@ -226,6 +259,45 @@ function mcpRow(
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence }
}

function deferRow(
base: ActReportRow, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
mcpClaimedServers: ReadonlySet<string>,
): ActReportRow {
// Sum only the servers no MCP row claims (see mcpClaimedServers in
// computeActReport), so the same schema tokens are never realized twice.
const counted = Object.entries(baseline.metrics).filter(([server]) => !mcpClaimedServers.has(server))
const excludedServers = Object.keys(baseline.metrics).length - counted.length
const perSessionTokens = counted.reduce((a, [, tokens]) => a + tokens, 0)
if (perSessionTokens === 0) {
return {
...base,
note: excludedServers > 0
? 'not measurable: every server in this baseline is already measured by an MCP remove/scope row'
: 'not measurable: empty baseline',
}
}
if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' }
const estimatedForWindow = Math.floor(perSessionTokens * sessions.length)
// A post-apply session realized the saving only if deferral actually became
// active in it. ENABLE_TOOL_SEARCH is read at process start, so sessions
// begun before the user restarted still run deferral-off — those aren't
// counted, and if none benefited we report it plainly rather than claim a
// saving that hasn't taken effect.
const deferredSessions = sessions.filter(sessionHasDeferralActive).length
const confidence = confidenceFor(sessions.length, baseline, afterStart, now)
if (deferredSessions === 0) {
return {
...base,
estimatedForWindow,
status: 'pending',
confidence,
note: `not yet in effect: deferral is still inactive in ${sessions.length} post-apply session${sessions.length === 1 ? '' : 's'} (takes effect on the next session; the client may not have restarted, or the change was reverted)`,
}
}
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * deferredSessions), confidence }
}

function archiveRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
Expand Down Expand Up @@ -358,7 +430,7 @@ async function modelDefaultRow(

async function computeRow(
rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date,
opts: ActReportOptions, modelDefaultProjectFound = true,
mcpClaimedServers: ReadonlySet<string>, opts: ActReportOptions, modelDefaultProjectFound = true,
): Promise<ActReportRow> {
const estimatedAtApply = rec.baseline?.estimatedTokens ?? 0
const base: ActReportRow = {
Expand All @@ -378,6 +450,7 @@ async function computeRow(
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 (DEFER_KINDS.has(rec.kind)) return deferRow(base, sessions, baseline, afterStart, now, mcpClaimedServers)
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' }
Expand Down Expand Up @@ -434,14 +507,28 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
const projects = await loadProjects({ start: windowStart, end: now })
const costRate = computeInputCostRate(projects)

// Servers a same-journal MCP row (mcp-remove / mcp-project-scope) already
// measures. Deferral baselines for defer-enable / defer-threshold span the
// whole observed MCP surface, so without this exclusion a defer row and an
// MCP row would both claim the same server's schema tokens over the same
// post-apply sessions, inflating totalRealizedTokens. Conservative by
// design: the defer row drops the server for its whole window even though
// pre-removal sessions were legitimately its own - under-claiming keeps the
// footer's "each fix measures only its own metric" literally true.
const mcpClaimedServers = new Set<string>()
for (const r of active) {
if (!MCP_KINDS.has(r.kind) || !r.baseline) continue
for (const server of Object.keys(r.baseline.metrics)) mcpClaimedServers.add(server)
}

const rows: ActReportRow[] = []
for (const rec of eligible) {
const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime()))
const modelDefaultWindow = rec.kind === 'model-default'
? modelDefaultSessionsInWindow(rec, projects, afterStart, now)
: undefined
const sessions = modelDefaultWindow?.sessions ?? sessionsInWindow(projects, afterStart, now)
rows.push(await computeRow(rec, sessions, afterStart, now, opts, modelDefaultWindow?.projectFound))
rows.push(await computeRow(rec, sessions, afterStart, now, mcpClaimedServers, opts, modelDefaultWindow?.projectFound))
}

const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind))
Expand Down Expand Up @@ -483,6 +570,7 @@ export function buildOptimizeAppliedHeader(report: ActReport): string | null {

function realizedCell(r: ActReportRow): string {
if (r.status === 'reverted') return 'reverted'
if (r.status === 'pending') return 'not yet in effect'
if (r.status === 'not-measurable') return 'not measurable'
if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)`
if (r.kind === 'model-default') return 'correlation'
Expand Down Expand Up @@ -585,7 +673,15 @@ function mcpServersFromApply(finding: WasteFinding): string[] {
}

function needsConfigBaseline(kind: ActionKind): boolean {
return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
return MCP_KINDS.has(kind) || DEFER_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
}

// Servers whose upfront schema deferral removes from the prefix. defer-alwaysload
// names them; defer-enable / defer-threshold re-enable deferral across the whole
// observed MCP surface.
function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
if (finding.apply?.kind === 'defer-alwaysload') return finding.apply.servers.map(s => s.server)
return observedMcpServers(ctx.projects)
}

export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
Expand All @@ -608,6 +704,19 @@ export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: Ca
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}

if (DEFER_KINDS.has(kind)) {
const servers = deferServers(finding, ctx)
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 : []
Expand Down
Loading
Loading