From 7187dd0da00871719fecac03f45d15ab4eceb1dc Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:59:12 +0530 Subject: [PATCH 01/13] fix(optimize): exclude sidechains from session heuristics (#974) --- README.md | 6 + src/optimize.ts | 49 ++++-- src/parser.ts | 2 + src/session-cache.ts | 2 +- src/types.ts | 5 + tests/optimize-sidechains.test.ts | 237 ++++++++++++++++++++++++++++ tests/parser-subagent-range.test.ts | 14 +- 7 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 tests/optimize-sidechains.test.ts diff --git a/README.md b/README.md index 2159e1da..8988a10d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: +For Claude Code, the optimize session count and session-level findings below +use user-started (main) sessions. Subagent sidechain transcripts are excluded +from that population because their delegated context and delivery behavior are +structurally different; their tokens, calls, and cost still count in all spend +totals and configuration-overhead findings. + - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) - Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise) diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..254231b2 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1,10 +1,11 @@ import chalk from 'chalk' -import { isReadShapedBashCommand } from './bash-utils.js' +import { createHash } from 'node:crypto' import { readdir, stat } from 'fs/promises' import { existsSync, statSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' +import { isReadShapedBashCommand } from './bash-utils.js' import { readSessionLines, readSessionFileSync } from './fs-utils.js' import { discoverAllSessions } from './providers/index.js' import { parseJsonlLine, shouldSkipLine } from './parser.js' @@ -2484,6 +2485,22 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number + session.totalCacheWriteTokens } +// Sidechain transcripts are real usage, so they stay in project totals and in +// token/cost calibration. They are not user-started sessions, however, and +// should never enter optimize heuristics whose unit is a human work session. +// Keep that distinction local to optimize instead of deleting sidechains from +// ProjectSummary, which would under-report the work delegated to subagents. +function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean { + return session.isSidechain !== true +} + +function optimizeSessionCount(projects: ProjectSummary[]): number { + return projects.reduce( + (total, project) => total + project.sessions.filter(isOptimizeSession).length, + 0, + ) +} + function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number { return session.totalInputTokens + session.totalCacheReadTokens * CACHE_READ_DISCOUNT @@ -2592,6 +2609,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand for (const project of projects) { for (const session of project.sessions) { + if (!isOptimizeSession(session)) continue if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue if (sessionDeliveryCommand(session)) continue @@ -2692,7 +2710,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB const candidates: ContextBloatCandidate[] = [] for (const project of projects) { - const sessions = [...project.sessions].sort((a, b) => + const sessions = project.sessions.filter(isOptimizeSession).sort((a, b) => new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime() ) let previousInputTokens: number | null = null @@ -2805,7 +2823,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio const outliers: Outlier[] = [] for (const project of projects) { - const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + const sessions = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) @@ -2866,7 +2884,7 @@ function findYoungProjectFirstSessionIds(projects: ProjectSummary[]): Set() for (const project of projects) { - const costed = project.sessions.filter(s => s.totalCostUSD > 0) + const costed = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (costed.length >= YOUNG_PROJECT_SESSION_LIMIT) continue let firstSession: ProjectSummary['sessions'][number] | null = null @@ -2985,15 +3003,25 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde // stale findings when cost/tokens moved (e.g. a re-price) while call count // held - reachable in the long-lived menubar process within the 60s TTL. // Cost is scaled to whole micro-dollars so float jitter cannot thrash the key. - let calls = 0, cost = 0, savings = 0, proxied = 0 + let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0 + const sidechainIdentities: string[] = [] for (const p of projects) { calls += p.totalApiCalls cost += p.totalCostUSD savings += p.totalSavingsUSD proxied += p.totalProxiedCostUSD + sessions += p.sessions.length + for (const session of p.sessions) { + if (session.isSidechain !== true) continue + sidechains++ + sidechainIdentities.push(`${p.projectPath}\0${session.sessionId}`) + } } + const sidechainDigest = createHash('sha256') + .update(sidechainIdentities.sort().join('\0')) + .digest('base64url') // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. - const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` + const fingerprint = `${projects.length}:${sessions}:${sidechains}:${sidechainDigest}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } @@ -3205,7 +3233,7 @@ function renderOptimize( const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' lines.push(' ' + [ - `${sessionCount} sessions`, + `${sessionCount} session${sessionCount === 1 ? '' : 's'}`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, @@ -3295,7 +3323,7 @@ export async function runOptimize( const result = await scanAndDetect(projects, dateRange) const { findings, costRate, healthScore, healthGrade } = result - const sessions = projects.flatMap(p => p.sessions) + const sessionCount = optimizeSessionCount(projects) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) @@ -3305,7 +3333,7 @@ export async function runOptimize( } const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) console.log(output) } @@ -3315,7 +3343,6 @@ export function buildOptimizeJsonReport( result: OptimizeResult, dateRange?: DateRange, ): OptimizeJsonReport { - const sessions = projects.flatMap(p => p.sessions) const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0) @@ -3335,7 +3362,7 @@ export function buildOptimizeJsonReport( healthGrade: result.healthGrade, findingCount: result.findings.length, periodCostUSD, - sessions: sessions.length, + sessions: optimizeSessionCount(projects), calls, potentialSavingsTokens, potentialSavingsCostUSD, diff --git a/src/parser.ts b/src/parser.ts index 712295b0..47aa9e05 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2251,6 +2251,7 @@ async function scanProjectDirs( // on a resumed session) and derive the agent id from the `agent-` // filename. A sidechain whose parent id was never captured stays standalone. if (cachedFile.isSidechain) { + session.isSidechain = true if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId } @@ -3327,6 +3328,7 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary): if (original.prLinks?.length) rebuilt.prLinks = original.prLinks if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory + if (original.isSidechain) rebuilt.isSidechain = true // prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at // the new boundary (see recomputeRangeStartPrRefs), not the wide range's value. if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..e9b6a163 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -100,7 +100,7 @@ export type CachedFile = { // is re-parsed only when the file changes (fingerprint differs). Carries no // turns, so it contributes no usage. (issue #441 follow-up) failed?: boolean - // Rich-session-capture, Claude session-level (capture-only; no report yet). + // Rich-session-capture, Claude session-level. // `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every // `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain: // parentUuid references an intra-file entry uuid, not another session id, so it diff --git a/src/types.ts b/src/types.ts index a51ae672..b6b57b5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,11 @@ export type SessionSummary = { /// correlations performed after all saved sessions have been parsed. prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt' source?: SessionSourceMetadata + /// Claude Code only: true when this record is a subagent (sidechain) + /// transcript rather than a user-started parent session. Sidechain spend is + /// real and remains in every cost/token/call aggregate; consumers that reason + /// about human session populations may exclude it explicitly. + isSidechain?: boolean // Claude Code only: agent type of a subagent transcript session // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for // ordinary sessions. Drives the Claude-scoped agent-type breakdown. diff --git a/tests/optimize-sidechains.test.ts b/tests/optimize-sidechains.test.ts new file mode 100644 index 00000000..e3d7d039 --- /dev/null +++ b/tests/optimize-sidechains.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/providers/index.js', async (importOriginal) => { + type ProvidersModule = typeof import('../src/providers/index.js') + const actual = await importOriginal() + return { + ...actual, + async discoverAllSessions() { + return [] + }, + } +}) + +import { + buildOptimizeJsonReport, + cacheKey, + computeInputCostRate, + detectSessionOutliers, + findContextBloatCandidates, + findLowWorthCandidates, + runOptimize, + scanAndDetect, + type OptimizeResult, +} from '../src/optimize.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +function session( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return { + sessionId, + project: 'app', + firstTimestamp: '2026-08-01T10:00:00.000Z', + lastTimestamp: '2026-08-01T10:30:00.000Z', + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 1_000, + totalOutputTokens: 1_000, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...overrides, + } +} + +function sidechain( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return session(sessionId, { + isSidechain: true, + parentSessionId: 'parent-session', + agentId: sessionId.replace(/^agent-/, ''), + ...overrides, + }) +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { + project: 'app', + projectPath: '/tmp/app', + sessions, + totalCostUSD: sessions.reduce((sum, item) => sum + item.totalCostUSD, 0), + totalSavingsUSD: sessions.reduce((sum, item) => sum + item.totalSavingsUSD, 0), + totalApiCalls: sessions.reduce((sum, item) => sum + item.apiCalls, 0), + totalProxiedCostUSD: 0, + } +} + +describe('optimize sidechain population (issue #974)', () => { + it('keeps sidechain spend out of the low-worth candidate population', () => { + const parent = session('parent', { + totalCostUSD: 4, + turns: [], + }) + const child = sidechain('agent-child', { + totalCostUSD: 12, + turns: [], + }) + + expect(findLowWorthCandidates([project([parent, child])]).map(item => item.sessionId)) + .toEqual(['parent']) + }) + + it('does not use a sidechain as a context-heavy candidate or growth baseline', () => { + const baseline = session('parent-baseline', { + firstTimestamp: '2026-08-01T10:00:00.000Z', + totalInputTokens: 20_000, + totalOutputTokens: 2_000, + }) + const child = sidechain('agent-child', { + firstTimestamp: '2026-08-02T10:00:00.000Z', + totalInputTokens: 200_000, + totalOutputTokens: 100, + }) + const candidate = session('parent-candidate', { + firstTimestamp: '2026-08-03T10:00:00.000Z', + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }) + + const candidates = findContextBloatCandidates([project([baseline, child, candidate])]) + + expect(candidates.map(item => item.sessionId)).toEqual(['parent-candidate']) + expect(candidates[0]!.growthRatio).toBe(5) + }) + + it('does not let a sidechain satisfy the peer-sample minimum for cost outliers', () => { + const sessions = [ + session('parent-cheap', { totalCostUSD: 1 }), + session('parent-expensive', { totalCostUSD: 10 }), + sidechain('agent-cheap', { totalCostUSD: 1 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('never reports an expensive sidechain as a parent-session cost outlier', () => { + const sessions = [ + session('parent-1', { totalCostUSD: 1 }), + session('parent-2', { totalCostUSD: 1 }), + session('parent-3', { totalCostUSD: 1 }), + sidechain('agent-expensive', { totalCostUSD: 100 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('counts only parent sessions while conserving sidechain cost, calls, and tokens', () => { + const projects = [project([ + session('parent', { + totalCostUSD: 3, + totalInputTokens: 100, + totalOutputTokens: 20, + apiCalls: 2, + }), + sidechain('agent-child', { + totalCostUSD: 7, + totalInputTokens: 900, + totalOutputTokens: 80, + apiCalls: 4, + }), + ])] + const result: OptimizeResult = { + findings: [], + costRate: computeInputCostRate(projects), + healthScore: 100, + healthGrade: 'A', + } + + const report = buildOptimizeJsonReport(projects, 'fixture', result) + + expect(report.summary.sessions).toBe(1) + expect(report.summary.periodCostUSD).toBe(10) + expect(report.summary.calls).toBe(6) + // Input-cost calibration keeps all spend and all input/cache tokens: + // ($10 * 0.7) / (100 + 900) tokens. + expect(report.summary.costRateUSD).toBeCloseTo(0.007, 12) + }) + + it('keeps sidechain spend out of every per-session finding in the optimize pipeline', async () => { + const projects = [project([ + sidechain('agent-only', { + totalCostUSD: 100, + totalInputTokens: 1_000_000, + totalOutputTokens: 100, + }), + ])] + + const result = await scanAndDetect(projects, { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + + expect(result.findings.map(finding => finding.id)).not.toContain('low-worth-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('context-heavy-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('cost-outliers') + }) + + it('uses the parent-session count in the text optimize headline', async () => { + const projects = [project([ + session('parent', { + totalCostUSD: 1, + bashBreakdown: { 'git commit -m shipped': { calls: 1 } }, + }), + sidechain('agent-child', { + totalCostUSD: 2, + bashBreakdown: { 'git commit -m irrelevant': { calls: 1 } }, + }), + ])] + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await runOptimize(projects, 'fixture', { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + const output = String(log.mock.calls.at(-1)?.[0] ?? '') + expect(output).toContain('1 session') + expect(output).not.toContain('2 sessions') + } finally { + log.mockRestore() + stderr.mockRestore() + } + }) + + it('separates cached optimize results when only sidechain classification changes', () => { + const range = { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + } + const parentOnly = project([session('same')]) + const sidechainOnly = project([sidechain('same')]) + + expect(parentOnly.totalCostUSD).toBe(sidechainOnly.totalCostUSD) + expect(parentOnly.totalApiCalls).toBe(sidechainOnly.totalApiCalls) + expect(cacheKey([parentOnly], range)).not.toBe(cacheKey([sidechainOnly], range)) + + const firstSidechain = project([session('first'), sidechain('second')]) + const secondSidechain = project([sidechain('first'), session('second')]) + expect(firstSidechain.sessions.length).toBe(secondSidechain.sessions.length) + expect(firstSidechain.totalCostUSD).toBe(secondSidechain.totalCostUSD) + expect(firstSidechain.sessions.filter(item => item.isSidechain).length) + .toBe(secondSidechain.sessions.filter(item => item.isSidechain).length) + expect(cacheKey([firstSidechain], range)).not.toBe(cacheKey([secondSidechain], range)) + }) +}) diff --git a/tests/parser-subagent-range.test.ts b/tests/parser-subagent-range.test.ts index cd50665a..40713fec 100644 --- a/tests/parser-subagent-range.test.ts +++ b/tests/parser-subagent-range.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { parseAllSessions, filterProjectsByDays, clearSessionCache } from '../src/parser.js' +import { clearLoadCacheMemo } from '../src/session-cache.js' import { loadPricing } from '../src/models.js' import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js' @@ -64,8 +65,16 @@ describe('subagent fold across a date-range boundary', () => { const projects = await parseAllSessions(range, 'claude') // The child session is present (its work is in range) as a standalone session. - const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`)) - expect(childPresent).toBe(true) + const child = projects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`) + expect(child).toBeDefined() + expect(child!.isSidechain).toBe(true) + // Drop both in-process memo layers so the second parse reloads the persisted + // session cache. The marker must survive that warm-disk path too, not only + // the cold transcript parse. + clearSessionCache() + clearLoadCacheMemo() + const warmProjects = await parseAllSessions(range, 'claude') + expect(warmProjects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The anchor parent (0 in-range turns) must NOT contaminate the sessions list; // it lives in subagentAnchors only. const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT)) @@ -101,6 +110,7 @@ describe('subagent fold across a date-range boundary', () => { const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20'])) expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor + expect(dayFiltered.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The child's spend still folds to the PR through the anchor. const row = aggregateByPr(dayFiltered).find(r => r.url === PR) From eb7ebb534c9ec45f7043cf1893e5fc1d5b1ad7d0 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:33:26 +0530 Subject: [PATCH 02/13] ci(desktop): guard Windows installer artifacts --- .github/workflows/build-windows-installer.yml | 60 ++++++++++++ app/DISTRIBUTION.md | 46 ++++++---- app/scripts/verify-windows-installer.mjs | 64 +++++++++++++ app/scripts/verify-windows-installer.test.ts | 92 +++++++++++++++++++ 4 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build-windows-installer.yml create mode 100644 app/scripts/verify-windows-installer.mjs create mode 100644 app/scripts/verify-windows-installer.test.ts diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml new file mode 100644 index 00000000..7638a707 --- /dev/null +++ b/.github/workflows/build-windows-installer.yml @@ -0,0 +1,60 @@ +name: Build Windows installer + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build-windows-installer.yml + - app/** + - src/** + - scripts/** + - package.json + - package-lock.json + push: + tags: + - 'desktop-v*' + +permissions: + contents: read + +jobs: + nsis: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - name: Install CLI dependencies + run: npm ci + + - name: Install desktop dependencies + run: npm ci --prefix app + + - name: Build NSIS installer + run: npm --prefix app run package:win + + - name: Verify installer manifest + shell: pwsh + run: | + if ($env:GITHUB_REF_TYPE -eq 'tag') { + node app/scripts/verify-windows-installer.mjs --tag $env:GITHUB_REF_NAME + } else { + node app/scripts/verify-windows-installer.mjs + } + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: CodeBurn-Windows-Installer + path: | + app/release/CodeBurn-Setup-*.exe + app/release/CodeBurn-Setup-*.exe.blockmap + if-no-files-found: error + retention-days: 14 diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 69165022..94255dac 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -3,10 +3,10 @@ This document describes how to produce distributable macOS, Windows, and Linux builds of the Electron desktop app. The macOS build is ad-hoc-signed and **not notarized** (no paid Apple Developer account); the Windows and Linux -builds are **unsigned**. There is no CI automation for any of this yet (unlike -the CLI and menubar release processes in `../RELEASING.md`) — packaging is run -by hand on a maintainer's machine. All three targets are produced by -`electron-builder` and can be cross-built from a single macOS host. +builds are **unsigned**. Windows NSIS packages are built and checked by the +`Build Windows installer` GitHub Actions workflow; the other desktop packages +are still produced by hand. All three targets are produced by +`electron-builder`. ## The bundled CLI (no install prerequisite) @@ -161,12 +161,11 @@ the NSIS and AppImage tooling on first run. ### Windows (`package:win`) -`electron-builder --win` produces a single artifact in `app/release/`: +`electron-builder --win` produces a single installer in `app/release/`: -- **`CodeBurn Setup 0.9.15.exe`** — the NSIS installer (the version number - tracks `package.json`; note the spaces in the filename). A `.exe.blockmap` - is written alongside it (differential-update metadata, unused — no - auto-updater yet). +- **`CodeBurn-Setup-0.9.15.exe`** — the NSIS installer (the version number + tracks `package.json`). A `.exe.blockmap` is written alongside it + (differential-update metadata, unused — no auto-updater yet). Config (`build.win` + `build.nsis`): @@ -236,8 +235,7 @@ taskbar/dock; it does not affect packaging or launch. ## Releases -There is no release CI for the desktop app yet (see the note at the top). When -a maintainer cuts a desktop release by hand, the GitHub tag convention is: +When a maintainer cuts a desktop release, the GitHub tag convention is: ``` desktop-v # e.g. desktop-v0.9.15 @@ -245,14 +243,24 @@ desktop-v # e.g. desktop-v0.9.15 This mirrors the menubar's `mac-v` convention (see `../RELEASING.md`) and keeps the desktop app's tags in their own namespace, separate from the CLI -(`v`) and the menubar (`mac-v`). Upload all of the artifacts -above — the four macOS `.dmg`/`.zip` files, `CodeBurn-Setup-.exe`, -and `CodeBurn-.AppImage` — to the GitHub Release created at that -tag. The website's download links **pin that tag** in their URLs, so the -release name and the artifact filenames must match exactly. (The Windows -installer uses an explicit `nsis.artifactName` of -`CodeBurn-Setup-${version}.${ext}` — electron-builder's default contains -spaces, which make ugly percent-encoded URLs.) +(`v`) and the menubar (`mac-v`). + +Pushing a `desktop-v` tag runs the `Build Windows installer` workflow +on `windows-latest`. The workflow requires the tag version, root package +version, and app package version to agree, and it fails unless the build emits +exactly one `CodeBurn-Setup-.exe` and one matching +`.exe.blockmap`. It uploads both files as the `CodeBurn-Windows-Installer` +Actions artifact. The workflow has read-only repository permissions and does +**not** publish release assets automatically. + +Before publishing the GitHub Release, the release owner must download that +workflow artifact and manually upload both Windows files along with the four +macOS `.dmg`/`.zip` files and `CodeBurn-.AppImage`. Confirm the live +release contains every required platform asset before announcing it. The +website's download links **pin that tag** in their URLs, so a release with a +missing installer is broken even when another Windows distribution channel is +available. The Windows installer uses an explicit `nsis.artifactName` of +`CodeBurn-Setup-${version}.${ext}`. ## Verifying a build diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs new file mode 100644 index 00000000..a135eb61 --- /dev/null +++ b/app/scripts/verify-windows-installer.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' + +function fail(message) { + console.error(`Windows installer manifest invalid: ${message}`) + process.exitCode = 1 +} + +function option(name, fallback) { + const index = process.argv.indexOf(name) + if (index === -1) return fallback + if (!process.argv[index + 1]) throw new Error(`${name} requires a value`) + return process.argv[index + 1] +} + +function packageVersion(path) { + return JSON.parse(readFileSync(path, 'utf8')).version +} + +function filesBelow(directory) { + return readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => basename(entry.name)) +} + +try { + const root = resolve(option('--root', new URL('../..', import.meta.url).pathname)) + const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) + const tag = option('--tag', '') + const rootVersion = packageVersion(join(root, 'package.json')) + const appVersion = packageVersion(join(root, 'app', 'package.json')) + + if (rootVersion !== appVersion) { + fail(`root version ${rootVersion} does not match app version ${appVersion}`) + } + + if (tag && tag !== `desktop-v${appVersion}`) { + fail(`${tag} does not match app version ${appVersion}`) + } + + const files = filesBelow(artifacts) + const expectedArtifacts = [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ] + for (const expected of expectedArtifacts) { + const count = files.filter(file => file === expected).length + if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + } + + const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) + const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) + if (unexpected.length > 0) { + fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) + } + + if (!process.exitCode) { + console.log(`Windows installer manifest verified for ${appVersion}`) + } +} catch (error) { + fail(error instanceof Error ? error.message : String(error)) +} diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts new file mode 100644 index 00000000..b8f488fc --- /dev/null +++ b/app/scripts/verify-windows-installer.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) + +function fixture(options: { + appVersion?: string + rootVersion?: string + files?: string[] + tag?: string +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-manifest-')) + const appDir = join(root, 'app') + const releaseDir = join(appDir, 'release') + mkdirSync(releaseDir, { recursive: true }) + + const appVersion = options.appVersion ?? '1.2.3' + writeFileSync(join(root, 'package.json'), JSON.stringify({ version: options.rootVersion ?? appVersion })) + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ version: appVersion })) + for (const file of options.files ?? [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ]) { + const path = join(releaseDir, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, 'fixture') + } + + const args = [verifier.pathname, '--root', root, '--artifacts', releaseDir] + if (options.tag) args.push('--tag', options.tag) + return spawnSync(process.execPath, args, { encoding: 'utf8' }) +} + +describe('Windows installer release manifest verifier', () => { + it('accepts one exact installer and blockmap for matching package versions and tag', () => { + const result = fixture({ tag: 'desktop-v1.2.3' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Windows installer manifest verified for 1.2.3') + }) + + it('rejects a desktop tag that does not match the app version', () => { + const result = fixture({ tag: 'desktop-v1.2.4' }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('desktop-v1.2.4 does not match app version 1.2.3') + }) + + it('rejects divergent root and app versions', () => { + const result = fixture({ rootVersion: '1.2.2' }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('root version 1.2.2 does not match app version 1.2.3') + }) + + it('rejects a missing installer blockmap', () => { + const result = fixture({ files: ['CodeBurn-Setup-1.2.3.exe'] }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0') + }) + + it('rejects duplicate expected artifacts in nested output directories', () => { + const result = fixture({ + files: [ + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + 'duplicate/CodeBurn-Setup-1.2.3.exe', + ], + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 2') + }) + + it('rejects stale installer artifacts from another version', () => { + const result = fixture({ + files: [ + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + 'CodeBurn-Setup-1.2.2.exe', + 'CodeBurn-Setup-1.2.2.exe.blockmap', + ], + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('unexpected Windows installer artifacts') + }) +}) From fe6d18357374a7f35d7d1789fa9e8bc4fe4c20d1 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:47:31 +0530 Subject: [PATCH 03/13] fix(release): harden Windows installer verification --- .github/workflows/build-windows-installer.yml | 30 +++++- RELEASING.md | 4 +- app/DISTRIBUTION.md | 9 +- app/scripts/verify-windows-installer.mjs | 91 +++++++++++++------ app/scripts/verify-windows-installer.test.ts | 55 ++++++++++- app/scripts/windows-installer-paths.d.mts | 1 + app/scripts/windows-installer-paths.mjs | 8 ++ 7 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 app/scripts/windows-installer-paths.d.mts create mode 100644 app/scripts/windows-installer-paths.mjs diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml index 7638a707..8373e4a2 100644 --- a/.github/workflows/build-windows-installer.yml +++ b/.github/workflows/build-windows-installer.yml @@ -2,6 +2,11 @@ name: Build Windows installer on: workflow_dispatch: + inputs: + release_tag: + description: Existing desktop-v* release to verify after manual asset upload + required: false + type: string pull_request: paths: - .github/workflows/build-windows-installer.yml @@ -13,12 +18,15 @@ on: push: tags: - 'desktop-v*' + release: + types: [published] permissions: contents: read jobs: nsis: + if: ${{ github.event_name != 'release' && !(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }} runs-on: windows-latest steps: - uses: actions/checkout@v6 @@ -37,6 +45,9 @@ jobs: - name: Install desktop dependencies run: npm ci --prefix app + - name: Test installer verifier + run: npm --prefix app test -- scripts/verify-windows-installer.test.ts + - name: Build NSIS installer run: npm --prefix app run package:win @@ -57,4 +68,21 @@ jobs: app/release/CodeBurn-Setup-*.exe app/release/CodeBurn-Setup-*.exe.blockmap if-no-files-found: error - retention-days: 14 + retention-days: 30 + + verify-release-assets: + if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'desktop-v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Verify live desktop release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" \ + --jq '[.assets[].name]' > "$RUNNER_TEMP/release-assets.json" + node app/scripts/verify-windows-installer.mjs \ + --tag "$RELEASE_TAG" \ + --release-assets "$RUNNER_TEMP/release-assets.json" diff --git a/RELEASING.md b/RELEASING.md index df1c6754..ab14983c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,9 @@ This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. -The Electron desktop app (`app/`) has no CI automation yet, but it is released manually under `desktop-v` tags: build the artifacts on a macOS host (see `app/DISTRIBUTION.md`) and `gh release upload desktop-v … --clobber` them onto the release. See `app/DISTRIBUTION.md` for how to build and distribute it as an ad-hoc-signed, non-notarized macOS build (plus unsigned Windows and Linux builds). +The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. + +Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. ## Versioning diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 94255dac..5fedd46d 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -249,9 +249,10 @@ Pushing a `desktop-v` tag runs the `Build Windows installer` workflow on `windows-latest`. The workflow requires the tag version, root package version, and app package version to agree, and it fails unless the build emits exactly one `CodeBurn-Setup-.exe` and one matching -`.exe.blockmap`. It uploads both files as the `CodeBurn-Windows-Installer` +`.exe.blockmap` at the top level of `app/release/`. It uploads those exact +top-level filenames as the `CodeBurn-Windows-Installer` Actions artifact. The workflow has read-only repository permissions and does -**not** publish release assets automatically. +**not** publish release assets automatically. Artifacts are retained for 30 days. Before publishing the GitHub Release, the release owner must download that workflow artifact and manually upload both Windows files along with the four @@ -262,6 +263,10 @@ missing installer is broken even when another Windows distribution channel is available. The Windows installer uses an explicit `nsis.artifactName` of `CodeBurn-Setup-${version}.${ext}`. +Publishing the Release triggers a read-only live-asset check. If the files are +uploaded afterward, rerun the workflow manually with `release_tag` set to the +existing `desktop-v` tag and require the verification job to pass. + ## Verifying a build ```sh diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs index a135eb61..dfd4b18a 100644 --- a/app/scripts/verify-windows-installer.mjs +++ b/app/scripts/verify-windows-installer.mjs @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs' import { basename, join, resolve } from 'node:path' +import { rootFromModuleUrl } from './windows-installer-paths.mjs' function fail(message) { console.error(`Windows installer manifest invalid: ${message}`) @@ -20,44 +21,78 @@ function packageVersion(path) { } function filesBelow(directory) { - return readdirSync(directory, { recursive: true, withFileTypes: true }) + return readdirSync(directory, { withFileTypes: true }) .filter(entry => entry.isFile()) .map(entry => basename(entry.name)) } -try { - const root = resolve(option('--root', new URL('../..', import.meta.url).pathname)) - const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) - const tag = option('--tag', '') - const rootVersion = packageVersion(join(root, 'package.json')) - const appVersion = packageVersion(join(root, 'app', 'package.json')) +function releaseVersion(tag) { + const match = /^desktop-v(.+)$/.exec(tag) + if (!match) throw new Error(`${tag || '(missing tag)'} is not a desktop release tag`) + return match[1] +} - if (rootVersion !== appVersion) { - fail(`root version ${rootVersion} does not match app version ${appVersion}`) +function verifyLiveRelease(tag, assetPath) { + const version = releaseVersion(tag) + const assets = JSON.parse(readFileSync(assetPath, 'utf8')) + if (!Array.isArray(assets) || assets.some(asset => typeof asset !== 'string')) { + throw new Error('release asset manifest must be a JSON array of names') } - - if (tag && tag !== `desktop-v${appVersion}`) { - fail(`${tag} does not match app version ${appVersion}`) - } - - const files = filesBelow(artifacts) - const expectedArtifacts = [ - `CodeBurn-Setup-${appVersion}.exe`, - `CodeBurn-Setup-${appVersion}.exe.blockmap`, + const required = [ + `CodeBurn-${version}-arm64.dmg`, + `CodeBurn-${version}.dmg`, + `CodeBurn-${version}-arm64-mac.zip`, + `CodeBurn-${version}-mac.zip`, + `CodeBurn-${version}.AppImage`, + `CodeBurn-Setup-${version}.exe`, + `CodeBurn-Setup-${version}.exe.blockmap`, ] - for (const expected of expectedArtifacts) { - const count = files.filter(file => file === expected).length - if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + for (const expected of required) { + const count = assets.filter(asset => asset === expected).length + if (count === 0) fail(`live release is missing ${expected}`) + if (count > 1) fail(`live release contains ${count} copies of ${expected}`) } + if (!process.exitCode) console.log(`Live desktop release assets verified for ${version}`) +} - const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) - const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) - if (unexpected.length > 0) { - fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) - } +try { + const tag = option('--tag', '') + const releaseAssets = option('--release-assets', '') + if (releaseAssets) { + verifyLiveRelease(tag, resolve(releaseAssets)) + } else { + const root = resolve(option('--root', rootFromModuleUrl(import.meta.url))) + const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) + const rootVersion = packageVersion(join(root, 'package.json')) + const appVersion = packageVersion(join(root, 'app', 'package.json')) - if (!process.exitCode) { - console.log(`Windows installer manifest verified for ${appVersion}`) + if (rootVersion !== appVersion) { + fail(`root version ${rootVersion} does not match app version ${appVersion}`) + } + + if (tag && tag !== `desktop-v${appVersion}`) { + fail(`${tag} does not match app version ${appVersion}`) + } + + const files = filesBelow(artifacts) + const expectedArtifacts = [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ] + for (const expected of expectedArtifacts) { + const count = files.filter(file => file === expected).length + if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + } + + const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) + const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) + if (unexpected.length > 0) { + fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) + } + + if (!process.exitCode) { + console.log(`Windows installer manifest verified for ${appVersion}`) + } } } catch (error) { fail(error instanceof Error ? error.message : String(error)) diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts index b8f488fc..0c63c4f0 100644 --- a/app/scripts/verify-windows-installer.test.ts +++ b/app/scripts/verify-windows-installer.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' +import { rootFromModuleUrl } from './windows-installer-paths.mjs' const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) @@ -34,7 +35,27 @@ function fixture(options: { return spawnSync(process.execPath, args, { encoding: 'utf8' }) } +function releaseFixture(files: string[]) { + const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-release-')) + const assets = join(root, 'assets.json') + writeFileSync(assets, JSON.stringify(files)) + return spawnSync(process.execPath, [ + verifier.pathname, + '--tag', + 'desktop-v1.2.3', + '--release-assets', + assets, + ], { encoding: 'utf8' }) +} + describe('Windows installer release manifest verifier', () => { + it('converts a Windows module URL into a valid drive-letter repository root', () => { + expect(rootFromModuleUrl( + 'file:///D:/a/codeburn/codeburn/app/scripts/verify-windows-installer.mjs', + true, + )).toBe('D:\\a\\codeburn\\codeburn') + }) + it('accepts one exact installer and blockmap for matching package versions and tag', () => { const result = fixture({ tag: 'desktop-v1.2.3' }) @@ -63,17 +84,16 @@ describe('Windows installer release manifest verifier', () => { expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0') }) - it('rejects duplicate expected artifacts in nested output directories', () => { + it('requires installer artifacts at the documented top-level output', () => { const result = fixture({ files: [ - 'CodeBurn-Setup-1.2.3.exe', 'CodeBurn-Setup-1.2.3.exe.blockmap', 'duplicate/CodeBurn-Setup-1.2.3.exe', ], }) expect(result.status).toBe(1) - expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 2') + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 0') }) it('rejects stale installer artifacts from another version', () => { @@ -89,4 +109,33 @@ describe('Windows installer release manifest verifier', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('unexpected Windows installer artifacts') }) + + it('accepts a complete live desktop release asset manifest', () => { + const result = releaseFixture([ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ]) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Live desktop release assets verified for 1.2.3') + }) + + it('rejects a live desktop release missing the Windows installer', () => { + const result = releaseFixture([ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ]) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe') + }) }) diff --git a/app/scripts/windows-installer-paths.d.mts b/app/scripts/windows-installer-paths.d.mts new file mode 100644 index 00000000..7074fa43 --- /dev/null +++ b/app/scripts/windows-installer-paths.d.mts @@ -0,0 +1 @@ +export function rootFromModuleUrl(moduleUrl: string | URL, windows?: boolean): string diff --git a/app/scripts/windows-installer-paths.mjs b/app/scripts/windows-installer-paths.mjs new file mode 100644 index 00000000..b2b0eb45 --- /dev/null +++ b/app/scripts/windows-installer-paths.mjs @@ -0,0 +1,8 @@ +import { posix, win32 } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function rootFromModuleUrl(moduleUrl, windows = process.platform === 'win32') { + const path = windows ? win32 : posix + const scriptPath = fileURLToPath(moduleUrl, { windows }) + return path.resolve(path.dirname(scriptPath), '..', '..') +} From 864991fe3fdca280c9dd695d95a3eef478a50c5c Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:01:00 +0530 Subject: [PATCH 04/13] fix(release): verify all desktop assets --- RELEASING.md | 2 +- app/DISTRIBUTION.md | 6 ++-- app/scripts/verify-windows-installer.mjs | 2 ++ app/scripts/verify-windows-installer.test.ts | 31 ++++++++++++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index ab14983c..83f739a2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,7 @@ This document describes the actual steps a maintainer takes to cut a CLI or macO The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. -Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. +Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. ## Versioning diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 5fedd46d..431c1900 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -256,8 +256,10 @@ Actions artifact. The workflow has read-only repository permissions and does Before publishing the GitHub Release, the release owner must download that workflow artifact and manually upload both Windows files along with the four -macOS `.dmg`/`.zip` files and `CodeBurn-.AppImage`. Confirm the live -release contains every required platform asset before announcing it. The +macOS `.dmg`/`.zip` files, `CodeBurn-.AppImage`, +`codeburn-desktop__amd64.deb`, and +`codeburn-desktop-.x86_64.rpm`. Confirm the live release contains +every required platform asset before announcing it. The website's download links **pin that tag** in their URLs, so a release with a missing installer is broken even when another Windows distribution channel is available. The Windows installer uses an explicit `nsis.artifactName` of diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs index dfd4b18a..84d499e6 100644 --- a/app/scripts/verify-windows-installer.mjs +++ b/app/scripts/verify-windows-installer.mjs @@ -44,6 +44,8 @@ function verifyLiveRelease(tag, assetPath) { `CodeBurn-${version}-arm64-mac.zip`, `CodeBurn-${version}-mac.zip`, `CodeBurn-${version}.AppImage`, + `codeburn-desktop_${version}_amd64.deb`, + `codeburn-desktop-${version}.x86_64.rpm`, `CodeBurn-Setup-${version}.exe`, `CodeBurn-Setup-${version}.exe.blockmap`, ] diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts index 0c63c4f0..2dd252f8 100644 --- a/app/scripts/verify-windows-installer.test.ts +++ b/app/scripts/verify-windows-installer.test.ts @@ -2,10 +2,12 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { rootFromModuleUrl } from './windows-installer-paths.mjs' const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) +const verifierPath = fileURLToPath(verifier) function fixture(options: { appVersion?: string @@ -30,7 +32,7 @@ function fixture(options: { writeFileSync(path, 'fixture') } - const args = [verifier.pathname, '--root', root, '--artifacts', releaseDir] + const args = [verifierPath, '--root', root, '--artifacts', releaseDir] if (options.tag) args.push('--tag', options.tag) return spawnSync(process.execPath, args, { encoding: 'utf8' }) } @@ -40,7 +42,7 @@ function releaseFixture(files: string[]) { const assets = join(root, 'assets.json') writeFileSync(assets, JSON.stringify(files)) return spawnSync(process.execPath, [ - verifier.pathname, + verifierPath, '--tag', 'desktop-v1.2.3', '--release-assets', @@ -117,6 +119,8 @@ describe('Windows installer release manifest verifier', () => { 'CodeBurn-1.2.3-arm64-mac.zip', 'CodeBurn-1.2.3-mac.zip', 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', 'CodeBurn-Setup-1.2.3.exe', 'CodeBurn-Setup-1.2.3.exe.blockmap', ]) @@ -132,10 +136,33 @@ describe('Windows installer release manifest verifier', () => { 'CodeBurn-1.2.3-arm64-mac.zip', 'CodeBurn-1.2.3-mac.zip', 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', 'CodeBurn-Setup-1.2.3.exe.blockmap', ]) expect(result.status).toBe(1) expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe') }) + + it.each([ + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', + ])('rejects a live desktop release missing %s', missing => { + const required = [ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ] + const result = releaseFixture(required.filter(asset => asset !== missing)) + + expect(result.status).toBe(1) + expect(result.stderr).toContain(`live release is missing ${missing}`) + }) }) From 8883ec44122b7f9fbbd7f205e13c684973b69a8b Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:17:28 +0530 Subject: [PATCH 05/13] docs: clarify authoritative Windows installer build --- RELEASING.md | 4 ++-- app/DISTRIBUTION.md | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 83f739a2..be443b3d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing CodeBurn -This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. +This document describes the actual steps a maintainer takes to cut CLI, macOS menubar, and Electron desktop releases. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. @@ -199,4 +199,4 @@ For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. ## Summary -The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. +The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 431c1900..56609fae 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -93,8 +93,9 @@ self-contained bundle into `app/build/cli`; see "The bundled CLI" above), then `vite`), then `electron-builder --mac` (whose `afterPack` hook copies the staged CLI into the app). `package:win` and `package:linux` mirror it exactly, swapping the final flag for `electron-builder --win` and `electron-builder ---linux`. All three can run on the same macOS host — electron-builder downloads -the NSIS and AppImage tooling on first use. +--linux`. Developers can run all three locally on the same macOS host — +electron-builder downloads the NSIS and AppImage tooling on first use. Release +Windows installers are built by the `windows-latest` workflow described below. ### Artifacts @@ -154,10 +155,12 @@ separate `electron-builder.yml`): ## Windows and Linux builds -Both are cross-built from the same macOS host used for the mac build — no -Windows or Linux machine, and no `wine`, is required. electron-builder 26 -embeds the Windows executable's icon/version resources natively and downloads -the NSIS and AppImage tooling on first run. +Developers can cross-build both locally from the same macOS host used for the +mac build — no Windows or Linux machine, and no `wine`, is required. +Release-authoritative Windows NSIS installers are instead built by the `Build +Windows installer` workflow on `windows-latest`. electron-builder 26 embeds the +Windows executable's icon/version resources natively and downloads the NSIS and +AppImage tooling on first run. ### Windows (`package:win`) From d841ea59d7a61a7a2180766ccae5b716b1516d61 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:44:11 +0530 Subject: [PATCH 06/13] fix: retain discovered durable history --- CHANGELOG.md | 2 ++ docs/providers/copilot.md | 3 ++- src/parser.ts | 7 ++++--- tests/parser.test.ts | 37 +++++++++++++++++++++++++++++++------ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477c7f4f..fc0b5e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) + ## 0.9.20 - 2026-08-10 ### Added diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 478687e2..30e97421 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -39,7 +39,8 @@ instead of trying to dedupe across stores. wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback. - **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived cache entries are never evicted when VS Code prunes old spans from the DB, so - month-to-date totals do not drop as the DB rotates. Entries age out after 90 days. + month-to-date totals do not drop as the DB rotates. Orphaned entries age out after + 90 days; sources still present in discovery remain cached regardless of call age. - **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot parse version, which discards the prior copilot cache. Spans already pruned from the DB before the upgrade cannot be recovered, so monotonicity starts from the upgrade point, diff --git a/src/parser.ts b/src/parser.ts index 712295b0..bdeae348 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3039,8 +3039,9 @@ async function parseProviderSources( } } - // 90-day age-out for durable providers: remove entries whose newest call is - // older than 90 days so the cache doesn't grow unboundedly over time. + // 90-day age-out for durable providers: prune only orphaned entries whose + // newest call is older than 90 days. Still-discovered sources remain live + // regardless of age and keep their persisted fingerprint for reuse. if (!readOnly && provider.durableSources) { const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000 for (const [cachedPath, cachedFile] of Object.entries(section.files)) { @@ -3049,7 +3050,7 @@ async function parseProviderSources( .map(c => new Date(c.timestamp).getTime()) .filter(ts => !isNaN(ts)) .reduce((max, ts) => Math.max(max, ts), 0) - if (newestTs > 0 && newestTs < cutoffMs) { + if (!allDiscoveredFiles.has(cachedPath) && newestTs > 0 && newestTs < cutoffMs) { delete section.files[cachedPath] ;(diskCache as { _dirty?: boolean })._dirty = true } diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 4bd0c5c2..a7e08ebe 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -23,6 +23,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr let _synthSources: SessionSource[] = [] let _synthDurable = false let _synthYields: ParsedProviderCall[] = [] +let _synthParseCalls = 0 vi.mock('../src/providers/index.js', async (importOriginal) => { type Mod = typeof import('../src/providers/index.js') @@ -52,6 +53,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => { createSessionParser(_s: SessionSource, _k: Set): SessionParser { return { async *parse(): AsyncGenerator { + _synthParseCalls++ for (const call of _synthYields) { // Respect seenKeys so that when multiple sources share the same // dedup key, only the first source yields it (mirrors real parsers). @@ -190,6 +192,7 @@ beforeEach(async () => { _synthSources = [] _synthDurable = false _synthYields = [] + _synthParseCalls = 0 }) afterEach(async () => { @@ -354,7 +357,7 @@ describe('(d) non-durable provider evicts deleted sources', () => { // (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained // ═══════════════════════════════════════════════════════════════════════════ describe('(e) 90-day age-out for durable providers', () => { - it('prunes an orphaned cache entry whose newest call is 91 days old', async () => { + it('keeps a discovered 91-day source persisted until discovery removes it', async () => { const synthFile = join(tmpHome, 'synth-age.txt') await writeFile(synthFile, 'placeholder') @@ -374,15 +377,37 @@ describe('(e) 90-day age-out for durable providers', () => { userMessage: 'old', sessionId: 'synth-old', }] - // First parse: cached with 91d-old timestamp → immediately pruned by 90-day check + // First refresh: a still-discovered durable source is live and persisted, + // regardless of the age of its newest call. const proj1 = await parseAllSessions(undefined, 'test-synthetic') - expect(totalOutput(proj1)).toBe(0) // pruned right away + expect.soft(totalOutput(proj1)).toBe(8) + expect.soft(_synthParseCalls).toBe(1) - // Confirm: entry is not in the persistent cache after first parse + const cache1 = await loadCache() + const persisted1 = cache1.providers['test-synthetic']?.files[synthFile] + expect.soft(persisted1).toBeDefined() + + // Second refresh: force the public seam through the persisted cache. The + // unchanged fingerprint must serve the cached parse without invoking the + // provider parser again. clearSessionCache() - _synthSources = [] // no longer discovered const proj2 = await parseAllSessions(undefined, 'test-synthetic') - expect(totalOutput(proj2)).toBe(0) + expect.soft(totalOutput(proj2)).toBe(8) + expect.soft(_synthParseCalls).toBe(1) + + const cache2 = await loadCache() + expect.soft(cache2.providers['test-synthetic']?.files[synthFile]?.fingerprint) + .toEqual(persisted1?.fingerprint) + + // Third refresh: once discovery removes the old source, it becomes an + // orphan and the durable 90-day age-out prunes it from results and disk. + clearSessionCache() + _synthSources = [] + const proj3 = await parseAllSessions(undefined, 'test-synthetic') + expect.soft(totalOutput(proj3)).toBe(0) + + const cache3 = await loadCache() + expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined() }) it('retains an orphaned cache entry whose newest call is 89 days old', async () => { From 7a3b4af9e9bf3002de45927f51c8c924bc84ad6a Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:51:54 +0530 Subject: [PATCH 07/13] fix(optimize): isolate sidechains from behavior --- README.md | 10 +-- src/act/model-defaults.ts | 8 +- src/optimize.ts | 32 +++++-- src/parser.ts | 5 +- src/session-population.ts | 34 ++++++++ src/workflow-insights.ts | 9 +- tests/act-model-defaults.test.ts | 7 ++ tests/optimize-fs.test.ts | 72 ++++++++++++++++ tests/optimize-sidechains.test.ts | 106 +++++++++++++++++++++++- tests/parser-large-json-scanner.test.ts | 2 + tests/parser-large-session.test.ts | 3 +- tests/workflow-insights.test.ts | 21 +++++ 12 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 src/session-population.ts diff --git a/README.md b/README.md index 8988a10d..3195ae13 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,11 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: -For Claude Code, the optimize session count and session-level findings below -use user-started (main) sessions. Subagent sidechain transcripts are excluded -from that population because their delegated context and delivery behavior are -structurally different; their tokens, calls, and cost still count in all spend -totals and configuration-overhead findings. +For Claude Code, the optimize session count, behavioral findings, coaching, and +model-default recommendations use user-started (main) sessions. Subagent +sidechain transcripts are excluded from that population because their delegated +context and delivery behavior are structurally different; their tokens, calls, +and cost still count in all spend totals and configuration-overhead findings. - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) diff --git a/src/act/model-defaults.ts b/src/act/model-defaults.ts index 537f6a0e..6e2e7615 100644 --- a/src/act/model-defaults.ts +++ b/src/act/model-defaults.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { aggregateModelStats, type ModelStats } from '../compare-stats.js' +import { withUserStartedSessions } from '../session-population.js' import type { ProjectSummary } from '../types.js' import { sha256File } from './backup.js' import type { ActionPlan } from './types.js' @@ -73,15 +74,16 @@ function isDebuggingHeavy(project: ProjectSummary): boolean { } export function recommendModelDefault(project: ProjectSummary, opts: { now?: Date } = {}): ModelDefaultRecommendation | null { + const behavioralProject = withUserStartedSessions(project) const now = opts.now ?? new Date() - const stats = aggregateModelStats([project]) + const stats = aggregateModelStats([behavioralProject]) .filter(s => s.model !== '' && s.editTurns >= MIN_EDIT_TURNS) .sort((a, b) => b.editTurns - a.editTurns || b.editCost - a.editCost) const current = stats[0] if (!current) return null - const providers = providerByModel(project) + const providers = providerByModel(behavioralProject) const provider = providers.get(current.model) if (!provider || !isRecent(current.lastSeen, now)) return null @@ -89,7 +91,7 @@ export function recommendModelDefault(project: ProjectSummary, opts: { now?: Dat const currentCost = costPerEdit(current) if (!Number.isFinite(currentCost) || currentCost <= 0) return null - const debuggingHeavy = isDebuggingHeavy(project) + const debuggingHeavy = isDebuggingHeavy(behavioralProject) const tolerance = debuggingHeavy ? 0 : ONE_SHOT_TOLERANCE const candidates = stats diff --git a/src/optimize.ts b/src/optimize.ts index 254231b2..0d3fbda5 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -13,6 +13,7 @@ import type { DateRange, ProjectSummary, SessionSummary } from './types.js' import { formatCost } from './currency.js' import { formatTokens } from './format.js' import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js' +import { isUserStartedSession, userStartedProjects } from './session-population.js' import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js' // ============================================================================ @@ -355,6 +356,7 @@ export type ToolCall = { sessionId: string project: string recent?: boolean + isSidechain?: boolean } export type ApiCallMeta = { @@ -480,6 +482,7 @@ export async function scanJsonlFile( const userMessages: string[] = [] const sessionId = basename(filePath, '.jsonl') let lastVersion = '' + let fileIsSidechain = false const skipThreshold = dateRange ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() @@ -495,6 +498,11 @@ export async function scanJsonlFile( if (!parsed) continue const entry = parsed as Record + if (entry.isSidechain === true && !fileIsSidechain) { + fileIsSidechain = true + for (const call of calls) call.isSidechain = true + } + if (entry.version && typeof entry.version === 'string') lastVersion = entry.version const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined @@ -545,6 +553,7 @@ export async function scanJsonlFile( sessionId, project, recent, + isSidechain: fileIsSidechain, }) } } @@ -656,6 +665,7 @@ export function loadMcpConfigs(projectCwds: Iterable, homeDir = homedir( // ============================================================================ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) const dirCounts = new Map() let totalJunkReads = 0 let recentJunkReads = 0 @@ -706,6 +716,7 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste } export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) const sessionFiles = new Map>() for (const call of calls) { @@ -1515,6 +1526,7 @@ function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): Capabi } export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null { + projects = userStartedProjects(projects) const candidates = findCapabilityReliabilityCandidates(projects) if (candidates.length === 0) return null @@ -2198,6 +2210,7 @@ export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWr export const BASH_TOOL_NAMES = new Set(['Bash', 'BashTool', 'PowerShellTool']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) let reads = 0 let edits = 0 let recentEdits = 0 @@ -2491,7 +2504,7 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number // Keep that distinction local to optimize instead of deleting sidechains from // ProjectSummary, which would under-report the work delegated to subagents. function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean { - return session.isSidechain !== true + return isUserStartedSession(session) } function optimizeSessionCount(projects: ProjectSummary[]): number { @@ -3038,6 +3051,7 @@ export async function scanAndDetect( if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data const costRate = computeInputCostRate(projects) + const behavioralProjects = userStartedProjects(projects) const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) const mcpCoverage = aggregateMcpCoverage(projects) @@ -3045,13 +3059,13 @@ export async function scanAndDetect( // Priority order for the per-session findings: low-worth → context-bloat → // outliers. Each later detector excludes sessions already named by an // earlier one so a single session is not listed in three findings. - const lowWorthSessionIds = new Set(findLowWorthCandidates(projects).map(c => c.sessionId)) + const lowWorthSessionIds = new Set(findLowWorthCandidates(behavioralProjects).map(c => c.sessionId)) const contextBloatVisibleIds = new Set( - findContextBloatCandidates(projects) + findContextBloatCandidates(behavioralProjects) .filter(c => !lowWorthSessionIds.has(c.sessionId)) .map(c => c.sessionId), ) - const firstSessionIds = findYoungProjectFirstSessionIds(projects) + const firstSessionIds = findYoungProjectFirstSessionIds(behavioralProjects) const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds]) const syncDetectors: Array<() => WasteFinding | null> = [ () => detectCacheBloat(apiCalls, projects, dateRange), @@ -3065,10 +3079,10 @@ export async function scanAndDetect( () => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls), () => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage), () => detectMcpDeferThreshold(projects, projectCwds), - () => detectCapabilityReliability(projects), - () => detectLowWorthSessions(projects), - () => detectContextBloat(projects, lowWorthSessionIds), - () => detectSessionOutliers(projects, outlierExclusions), + () => detectCapabilityReliability(behavioralProjects), + () => detectLowWorthSessions(behavioralProjects), + () => detectContextBloat(behavioralProjects, lowWorthSessionIds), + () => detectSessionOutliers(behavioralProjects, outlierExclusions), () => detectBloatedClaudeMd(projectCwds), () => detectBashBloat(), ] @@ -3088,7 +3102,7 @@ export async function scanAndDetect( const { score, grade } = computeHealth(findings) const modelRecommendations: ModelDefaultRecommendation[] = [] - for (const project of projects) { + for (const project of behavioralProjects) { const rec = recommendModelDefault(project, { now: dateRange?.end }) if (rec) modelRecommendations.push(rec) } diff --git a/src/parser.ts b/src/parser.ts index 47aa9e05..861b05e6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -531,7 +531,7 @@ function extractObjectFields( return captured } -const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const +const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const function parseLargeJsonl(line: string | Buffer): JournalEntry | null { @@ -545,6 +545,9 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null { if (!type) return null const entry: JournalEntry = { type } + if (root['isSidechain']?.kind === 'scalar' && source.slice(root['isSidechain'].start, root['isSidechain'].end) === 'true') { + entry.isSidechain = true + } const timestamp = readJsonString(source, root['timestamp']) const sessionId = readJsonString(source, root['sessionId']) const cwd = readJsonString(source, root['cwd']) diff --git a/src/session-population.ts b/src/session-population.ts new file mode 100644 index 00000000..965f3591 --- /dev/null +++ b/src/session-population.ts @@ -0,0 +1,34 @@ +import type { ProjectSummary, SessionSummary } from './types.js' + +/** + * Sidechains are real usage, but they are not user-started work sessions. + * Behavioral consumers should use this predicate or the projected project + * view below; accounting and configuration consumers should use the originals. + */ +export function isUserStartedSession(session: SessionSummary): boolean { + return session.isSidechain !== true +} + +export function withUserStartedSessions(project: ProjectSummary): ProjectSummary { + const sessions = project.sessions.filter(isUserStartedSession) + if (sessions.length === project.sessions.length) return project + + const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0) + return { + ...project, + sessions, + totalCostUSD, + totalSavingsUSD: sessions.reduce((sum, session) => sum + session.totalSavingsUSD, 0), + totalEstimatedCostUSD: project.totalEstimatedCostUSD === undefined + ? undefined + : sessions.reduce((sum, session) => sum + (session.totalEstimatedCostUSD ?? 0), 0), + totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0), + // Proxy coverage is project-scoped and applies to every retained session + // whenever it applies to the source project. + totalProxiedCostUSD: project.totalProxiedCostUSD > 0 ? totalCostUSD : 0, + } +} + +export function userStartedProjects(projects: ProjectSummary[]): ProjectSummary[] { + return projects.map(withUserStartedSessions) +} diff --git a/src/workflow-insights.ts b/src/workflow-insights.ts index 3407fc92..f596a789 100644 --- a/src/workflow-insights.ts +++ b/src/workflow-insights.ts @@ -1,6 +1,7 @@ import { homedir } from 'os' import { EDIT_TOOLS } from './classifier.js' +import { userStartedProjects } from './session-population.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' // User-side mirror of compare-stats.ts scanSelfCorrections (which scans the @@ -44,7 +45,7 @@ export type UserCorrectionStats = { export function scanUserCorrections(projects: ProjectSummary[]): UserCorrectionStats { let corrections = 0 let userTurns = 0 - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { // A correction is a FOLLOW-UP by definition: the session's opening // prompt cannot be correcting this assistant, however correction-shaped @@ -95,7 +96,7 @@ export function sessionTimeToFirstEditMs(session: ProjectSummary['sessions'][num export function medianTimeToFirstEditMs(projects: ProjectSummary[]): number | null { const samples: number[] = [] - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { const ms = sessionTimeToFirstEditMs(session) if (ms !== null) samples.push(ms) @@ -140,7 +141,7 @@ export function aggregateFileChurn(projects: ProjectSummary[], limit = 15): Rewo type Acc = { path: string; sessions: Set; edits: number } const byPath = new Map() - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { for (const turn of session.turns) { for (const call of turn.assistantCalls) { @@ -191,7 +192,7 @@ export const MIN_ONE_SHOT_EDIT_TURNS = 5 /// model-efficiency and the report's category one-shot figures. export function worstOneShotCategory(projects: ProjectSummary[], minEditTurns = MIN_ONE_SHOT_EDIT_TURNS): CategoryOneShot | null { const acc = new Map() - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { for (const [cat, d] of Object.entries(session.categoryBreakdown)) { const e = acc.get(cat) ?? { editTurns: 0, oneShotTurns: 0 } diff --git a/tests/act-model-defaults.test.ts b/tests/act-model-defaults.test.ts index 6de3651b..bcdfcdbd 100644 --- a/tests/act-model-defaults.test.ts +++ b/tests/act-model-defaults.test.ts @@ -212,6 +212,13 @@ describe('model default recommendations', () => { expect(recommendModelDefault(project, { now: NOW })).toBeNull() }) + + it('never recommends an actionable default from sidechain-only behavior', () => { + const project = recommendationProject() + project.sessions[0]!.isSidechain = true + + expect(recommendModelDefault(project, { now: NOW })).toBeNull() + }) }) describe('model default apply plan', () => { diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 2364e08a..0baf316f 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -20,6 +20,9 @@ import { detectUnusedMcp, detectBashBloat, detectGhostCommands, + detectDuplicateReads, + detectJunkReads, + detectLowReadEditRatio, loadMcpConfigs, scanJsonlFile, scanAndDetect, @@ -294,6 +297,75 @@ describe('scanJsonlFile', () => { expect(result.calls[0].name).toBe('Read') }) + it('marks tool calls from sidechain transcript entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'agent-reviewer.jsonl') + const now = new Date().toISOString() + writeFile(filePath, JSON.stringify({ + type: 'assistant', isSidechain: true, timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: '/x/foo.ts' } }] }, + })) + + const result = await scanJsonlFile(filePath, 'p1', undefined) + + expect(result.calls).toHaveLength(1) + expect(result.calls[0]!.isSidechain).toBe(true) + }) + + it('classifies every tool call in a transcript when a later large entry marks it as sidechain', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'agent-reviewer.jsonl') + const now = new Date().toISOString() + const assistant = (name: string, isSidechain?: boolean, padding = '') => JSON.stringify({ + type: 'assistant', + ...(isSidechain === true ? { isSidechain: true } : {}), + timestamp: now, + cwd: '/x', + padding, + message: { + model: 'claude-sonnet-4-5', + usage: { cache_creation_input_tokens: 1 }, + content: [{ type: 'tool_use', name, input: { file_path: `/x/${name}.ts` } }], + }, + }) + writeFile(filePath, [ + JSON.stringify({ type: 'user', timestamp: now, cwd: '/x', message: { content: 'delegate this' } }), + assistant('Read'), + assistant('Edit', true, 'x'.repeat(40_000)), + assistant('Bash'), + ].join('\n')) + + const result = await scanJsonlFile(filePath, 'p1', undefined) + + expect(result.calls.map(call => [call.name, call.isSidechain])).toEqual([ + ['Read', true], + ['Edit', true], + ['Bash', true], + ]) + expect(result.apiCalls).toHaveLength(3) + expect(result.cwds).toHaveLength(4) + expect(result.userMessages).toEqual(['delegate this']) + }) + + it('excludes marked sidechain calls from raw human-behavior detectors', () => { + const editCalls = Array.from({ length: 10 }, (_, index) => ({ + name: 'Edit', input: { file_path: `/src/${index}.ts` }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + const junkReads = Array.from({ length: 6 }, () => ({ + name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + const repeatReads = Array.from({ length: 6 }, () => ({ + name: 'Read', input: { file_path: '/app/src/a.ts' }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + + expect(detectLowReadEditRatio(editCalls)).toBeNull() + expect(detectJunkReads(junkReads)).toBeNull() + expect(detectDuplicateReads(repeatReads)).toBeNull() + }) + it('skips malformed JSONL lines without crashing', async () => { const root = makeFixtureRoot() const filePath = join(root, 'session.jsonl') diff --git a/tests/optimize-sidechains.test.ts b/tests/optimize-sidechains.test.ts index e3d7d039..c54d9b45 100644 --- a/tests/optimize-sidechains.test.ts +++ b/tests/optimize-sidechains.test.ts @@ -15,6 +15,7 @@ import { buildOptimizeJsonReport, cacheKey, computeInputCostRate, + detectCapabilityReliability, detectSessionOutliers, findContextBloatCandidates, findLowWorthCandidates, @@ -22,7 +23,47 @@ import { scanAndDetect, type OptimizeResult, } from '../src/optimize.js' -import type { ProjectSummary, SessionSummary } from '../src/types.js' +import type { ClassifiedTurn, ProjectSummary, SessionSummary } from '../src/types.js' + +function behavioralTurn( + model: string, + index: number, + options: { retries?: number; costUSD?: number; userMessage?: string } = {}, +): ClassifiedTurn { + const timestamp = new Date(Date.parse('2026-08-01T10:00:00.000Z') + index * 1_000).toISOString() + return { + userMessage: options.userMessage ?? 'edit the code', + timestamp, + sessionId: 'agent-behavior', + category: 'feature', + retries: options.retries ?? 0, + hasEdits: true, + assistantCalls: [{ + provider: 'claude', + model, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: options.costUSD ?? 1, + tools: ['Edit'], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp, + bashCommands: [], + deduplicationKey: `${model}-${index}`, + }], + } +} function session( sessionId: string, @@ -78,6 +119,69 @@ function project(sessions: SessionSummary[]): ProjectSummary { } describe('optimize sidechain population (issue #974)', () => { + it('does not recommend a model default from sidechain-only edit behavior', async () => { + const sonnetTurns = Array.from({ length: 35 }, (_, index) => + behavioralTurn('claude-sonnet-4-20250514', index, { + retries: index >= 32 ? 1 : 0, + costUSD: 2, + })) + const haikuTurns = Array.from({ length: 32 }, (_, index) => + behavioralTurn('claude-haiku-3-5-20241022', index + 35, { + retries: index >= 29 ? 1 : 0, + costUSD: 0.9, + })) + const child = sidechain('agent-behavior', { turns: [...sonnetTurns, ...haikuTurns] }) + const projects = [project([child])] + + const result = await scanAndDetect(projects, { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + + expect(result.modelRecommendations).toEqual([]) + }) + + it('does not emit coaching from sidechain-only correction behavior', () => { + const turns = Array.from({ length: 66 }, (_, index) => + behavioralTurn('claude-sonnet-4-20250514', index, { + userMessage: index === 0 ? 'review the code' : 'you missed the edge case', + })) + const child = sidechain('agent-corrections', { + totalCostUSD: 9, + totalInputTokens: 6_600, + totalOutputTokens: 3_300, + apiCalls: 66, + turns, + }) + const projects = [project([child])] + const result: OptimizeResult = { + findings: [], + costRate: computeInputCostRate(projects), + healthScore: 100, + healthGrade: 'A', + modelRecommendations: [], + } + + const report = buildOptimizeJsonReport(projects, 'fixture', result) + + expect(report.coachingNotes).toEqual([]) + expect(report.summary.periodCostUSD).toBe(9) + expect(report.summary.calls).toBe(66) + }) + + it('does not report retry-heavy capabilities from sidechain-only edits', () => { + const turns = Array.from({ length: 5 }, (_, index) => { + const item = behavioralTurn('claude-sonnet-4-20250514', index, { + retries: index < 3 ? 1 : 0, + }) + item.assistantCalls[0]!.skills = ['reviewer'] + return item + }) + const child = sidechain('agent-capability', { turns }) + + expect(detectCapabilityReliability([project([child])])).toBeNull() + }) + it('keeps sidechain spend out of the low-worth candidate population', () => { const parent = session('parent', { totalCostUSD: 4, diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts index af0668b0..00ebe5de 100644 --- a/tests/parser-large-json-scanner.test.ts +++ b/tests/parser-large-json-scanner.test.ts @@ -21,6 +21,7 @@ function largeUserLine(): string { function largeAssistantLine(): string { return JSON.stringify({ type: 'assistant', + isSidechain: true, sessionId: 's1', timestamp: '2026-05-01T00:00:01Z', cwd: '/repo', @@ -55,6 +56,7 @@ describe('large JSONL compact scanner', () => { it('extracts capped tool inputs needed by optimize', () => { const parsed = parseJsonlLine(Buffer.from(largeAssistantLine())) + expect(parsed?.isSidechain).toBe(true) const msg = parsed?.message expect(msg?.role).toBe('assistant') if (msg?.role !== 'assistant') return diff --git a/tests/parser-large-session.test.ts b/tests/parser-large-session.test.ts index 9ef1b8bc..c4d6a07c 100644 --- a/tests/parser-large-session.test.ts +++ b/tests/parser-large-session.test.ts @@ -65,7 +65,7 @@ function assistantLine(sessionId: string, timestamp: string, messageId: string, function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string { const hugeText = 'y'.repeat(3_000_000) - return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` + return `{"parentUuid":"u1","isSidechain":true,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` } function attachmentLine(sessionId: string, timestamp: string): string { @@ -227,6 +227,7 @@ describe('parseAllSessions with large Claude fixture', () => { expect(projects.length).toBeGreaterThan(0) const sess = projects[0]!.sessions[0]! + expect(sess.isSidechain).toBe(true) expect(sess.apiCalls).toBe(1) expect(sess.totalInputTokens).toBe(1000) expect(sess.totalOutputTokens).toBe(100) diff --git a/tests/workflow-insights.test.ts b/tests/workflow-insights.test.ts index 2e44687e..bb18d759 100644 --- a/tests/workflow-insights.test.ts +++ b/tests/workflow-insights.test.ts @@ -353,4 +353,25 @@ describe('review-findings regressions', () => { // 95 local calls + 5 unpriced cloud calls: coverage must be 0, not 0.95. expect(computePricingCoverage(5, 5)).toBe(0) }) + + it('excludes sidechain-only work from every human workflow signal', () => { + const editCall = call({ + tools: ['Edit'], + timestamp: '2026-06-01T10:06:00Z', + toolSequence: [[{ tool: 'Edit', file: '/home/u/app/src/a.ts' }]], + }) + const sidechain = session('agent-reviewer', [ + turn({ userMessage: 'review the change', timestamp: '2026-06-01T10:00:00Z' }), + turn({ userMessage: 'you missed the edge case', calls: [editCall], timestamp: '2026-06-01T10:06:00Z' }), + turn({ userMessage: 'that is still wrong', timestamp: '2026-06-01T10:07:00Z' }), + turn({ userMessage: 'revert that change', timestamp: '2026-06-01T10:08:00Z' }), + ], { feature: cat(10, 0) } as SessionSummary['categoryBreakdown']) + sidechain.isSidechain = true + const projects = [project([sidechain])] + + expect(scanUserCorrections(projects)).toEqual({ corrections: 0, userTurns: 0, correctionRate: null }) + expect(medianTimeToFirstEditMs(projects)).toBeNull() + expect(aggregateFileChurn(projects)).toEqual([]) + expect(worstOneShotCategory(projects)).toBeNull() + }) }) From 11173758b5721a9e5dd25af6198076ac2ede3400 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:40:21 +0300 Subject: [PATCH 08/13] fix(models): let --unpriced survive --top, and document the flag `--top` is applied inside `aggregateModels`, before the unpriced filter runs, on rows sorted by cost + savings descending. Unpriced rows are $0 on both -- `findUnpricedModels` excludes anything carrying a local-savings baseline -- so they always sort last, and any `--top N` smaller than the number of priced models removed exactly the rows `--unpriced` exists to show. In table form the user then read "No model usage found for the selected period", which is the wrong answer twice over: they do have unpriced models, and nothing tells them the two flags fought. The slice now happens after the filter when `--unpriced` is set. Also adds the flag to the README models table and a changelog entry -- it was added for #969, and a filter nobody can discover does not help anyone find their unpriced models. Follow-up to #985. --- CHANGELOG.md | 4 +++ README.md | 1 + src/main.ts | 9 ++++++- tests/models-report.test.ts | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..7a68d13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969) + ### Changed - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. @@ -15,6 +18,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed +- **`codeburn models --unpriced --top N` returned nothing.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted by cost + savings descending — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter. (#969) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/README.md b/README.md index 2159e1da..da3bc888 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --by-task` | Break each model into per-task-type rows | | `codeburn models --by-agent` | Break each model into per-agent rows: which agent drove which model's spend (`(main)` covers non-agent sessions; `--min-cost 0` shows sub-cent agents) | | `codeburn models --top 10` | Only the 10 most expensive models | +| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning | | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | diff --git a/src/main.ts b/src/main.ts index a8af9bba..192cb72b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2100,11 +2100,17 @@ program } const projects = await parseAllSessions(range, opts.provider) + const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, + // `aggregateModels` slices to topN on rows sorted by cost + savings + // descending. Unpriced rows are $0 on both (findUnpricedModels excludes + // anything with a local-savings baseline), so they always sort last and + // `--top` would remove exactly the rows `--unpriced` exists to show. + // Take the whole set here and slice after filtering instead. + topN: opts.unpriced ? undefined : topN, minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) if (opts.unpriced) { @@ -2114,6 +2120,7 @@ program cost: row.costUSD, tokens: row.totalTokens, }]).length > 0) + if (topN !== undefined) rows = rows.slice(0, topN) } const fmt = (opts.format ?? 'table').toLowerCase() diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 8808ca58..426f3eaa 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -776,6 +776,55 @@ describe('models CLI breakdown flags', () => { } }) + // `--top` is applied inside aggregateModels, before the unpriced filter runs, + // on rows sorted by cost + savings descending. Unpriced rows are $0 on both, + // so they sort last and a small --top removed exactly the rows --unpriced + // exists to surface: the user was told they had no unpriced models. + it('keeps unpriced rows when --unpriced is combined with --top', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced-top') + await mkdir(projectDir, { recursive: true }) + const assistant = (id: string, model: string, timestamp: string, input: number) => JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-top-session', + timestamp, + cwd: '/tmp/models-unpriced-top', + message: { + id, type: 'message', role: 'assistant', model, + content: [{ type: 'text', text: id }], + usage: { input_tokens: input, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-top-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced-top', + message: { role: 'user', content: 'Two priced models outrank the unpriced one.' }, + }), + // Both priced models cost more than the $0 unpriced row, so they take + // both --top slots unless the filter runs first. + assistant('opus', 'claude-opus-4-6', '2026-05-09T00:01:00.000Z', 5000), + assistant('sonnet', 'claude-sonnet-4-6', '2026-05-09T00:02:00.000Z', 3000), + assistant('unpriced', 'zz-unpriced-frontier-model', '2026-05-09T00:03:00.000Z', 2000), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--top', '2', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, From d81fca306686cfd64d8dc2f290665720ab1554d5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:34:51 +0300 Subject: [PATCH 09/13] fix(models): rank unpriced rows before --top slices them Filtering before the slice was necessary but not sufficient. Every unpriced row is $0 on both cost and savings -- findUnpricedModels excludes anything carrying a local-savings baseline -- so they all tie under aggregateModels' sort key, and Array#sort is stable. The surviving order was Map insertion order: the order each model's first assistant call appears in the transcript. So --unpriced --top N kept the N that showed up earliest, and a model holding almost all of the unpriced volume was dropped if it appeared late. findUnpricedModels already sorts by tokens descending, then calls, then model name, and the dashboard warning renders that order. It is now called once over the whole row set, its order becomes a rank index, and the rows are ranked before the slice -- so the CLI and the warning agree on which N, which is what the README row claims. In the breakdown modes several rows share one model, so they share that model's rank and N still counts rows. The previous test could not catch this: its fixture held one unpriced model, so --top 2 never truncated anything and deleting the slice line left the suite green. It now uses three unpriced models emitted in an order that differs from their size order, and asserts which two survive rather than only how many. --- CHANGELOG.md | 2 +- src/main.ts | 22 +++++++++++++++------- tests/models-report.test.ts | 21 ++++++++++----------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a68d13d..3d305d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed -- **`codeburn models --unpriced --top N` returned nothing.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted by cost + savings descending — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter. (#969) +- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/src/main.ts b/src/main.ts index 192cb72b..25d8df08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2105,21 +2105,29 @@ program byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - // `aggregateModels` slices to topN on rows sorted by cost + savings - // descending. Unpriced rows are $0 on both (findUnpricedModels excludes - // anything with a local-savings baseline), so they always sort last and - // `--top` would remove exactly the rows `--unpriced` exists to show. - // Take the whole set here and slice after filtering instead. + // `aggregateModels` filters and slices before the unpriced filter. Its + // rows are sorted cost-first, so a small --top would remove exactly the + // rows `--unpriced` exists to show. Take the whole set here and slice + // after filtering and ranking instead. topN: opts.unpriced ? undefined : topN, minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) if (opts.unpriced) { - rows = rows.filter(row => findUnpricedModels([{ + const unpriced = findUnpricedModels(rows.map(row => ({ model: row.model, calls: row.calls, cost: row.costUSD, tokens: row.totalTokens, - }]).length > 0) + }))) + const unpricedRank = new Map() + for (const [rank, usage] of unpriced.entries()) { + // Breakdown modes can emit several rows for one model. Keep the first + // rank so all rows for that model stay together and N still counts rows. + if (!unpricedRank.has(usage.model)) unpricedRank.set(usage.model, rank) + } + rows = rows + .filter(row => unpricedRank.has(row.model)) + .sort((a, b) => (unpricedRank.get(a.model)! - unpricedRank.get(b.model)!)) if (topN !== undefined) rows = rows.slice(0, topN) } diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 426f3eaa..4e1dba09 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -776,10 +776,8 @@ describe('models CLI breakdown flags', () => { } }) - // `--top` is applied inside aggregateModels, before the unpriced filter runs, - // on rows sorted by cost + savings descending. Unpriced rows are $0 on both, - // so they sort last and a small --top removed exactly the rows --unpriced - // exists to surface: the user was told they had no unpriced models. + // Unpriced rows all sort at $0 in aggregateModels, so the old implementation + // preserved transcript/Map order instead of findUnpricedModels' token order. it('keeps unpriced rows when --unpriced is combined with --top', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-')) try { @@ -802,13 +800,13 @@ describe('models CLI breakdown flags', () => { sessionId: 'models-unpriced-top-session', timestamp: '2026-05-09T00:00:00.000Z', cwd: '/tmp/models-unpriced-top', - message: { role: 'user', content: 'Two priced models outrank the unpriced one.' }, + message: { role: 'user', content: 'Three unpriced models arrive small-first.' }, }), - // Both priced models cost more than the $0 unpriced row, so they take - // both --top slots unless the filter runs first. - assistant('opus', 'claude-opus-4-6', '2026-05-09T00:01:00.000Z', 5000), - assistant('sonnet', 'claude-sonnet-4-6', '2026-05-09T00:02:00.000Z', 3000), - assistant('unpriced', 'zz-unpriced-frontier-model', '2026-05-09T00:03:00.000Z', 2000), + // Transcript order is deliberately different from token order: + // 1.1k, 9.1k, 5.1k total tokens. The two largest must survive --top 2. + assistant('small', 'zz-unpriced-small', '2026-05-09T00:01:00.000Z', 1000), + assistant('largest', 'zz-unpriced-largest', '2026-05-09T00:02:00.000Z', 9000), + assistant('middle', 'zz-unpriced-middle', '2026-05-09T00:03:00.000Z', 5000), ].join('\n') + '\n') const res = spawnSync( @@ -819,7 +817,8 @@ describe('models CLI breakdown flags', () => { expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) const rows = JSON.parse(res.stdout) as Array<{ model: string }> - expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows).toHaveLength(2) + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-largest', 'zz-unpriced-middle']) } finally { await rm(home, { recursive: true, force: true }) } From 2d35c8fa242574a073a60f1ea4c8d48c8851ce3f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:52:43 -0700 Subject: [PATCH 10/13] test(parser): cover durable retention through a month-scoped refresh --- tests/parser.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 6582a97c..44f9db73 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -416,6 +416,45 @@ describe('(e) 90-day age-out for durable providers', () => { expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined() }) + it('keeps a discovered 91-day source through a month-scoped refresh', async () => { + const synthFile = join(tmpHome, 'synth-scoped.txt') + await writeFile(synthFile, 'placeholder') + + const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString() + + _synthDurable = true + _synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 8, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.002, tools: [], bashCommands: [], + timestamp: ts91dAgo, + speed: 'standard', + deduplicationKey: 'synth-age-out-91d-scoped', + userMessage: 'old', sessionId: 'synth-old-scoped', + }] + + expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) + + // A today-ranged refresh loads under a month scope that excludes the entry's + // shard. Durable providers are never scoped, so the age-out still sees the + // entry as discovered and the save must carry its month across intact. + clearSessionCache() + const today = new Date() + const start = new Date(today); start.setHours(0, 0, 0, 0) + const end = new Date(today); end.setHours(23, 59, 59, 999) + expect.soft(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(0) + + clearSessionCache() + expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) + expect.soft(_synthParseCalls).toBe(1) + + const cache = await loadCache() + expect.soft(cache.providers['test-synthetic']?.files[synthFile]).toBeDefined() + }) + it('retains an orphaned cache entry whose newest call is 89 days old', async () => { const synthFile = join(tmpHome, 'synth-retain.txt') await writeFile(synthFile, 'placeholder') From 8ecd14ccdf593ba9e2e2f73849ff0b8c9365bbdd Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:59:51 -0700 Subject: [PATCH 11/13] changelog: sidechains leave the optimize session population (#974) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c444ad37..b6ad4d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed +- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). The session count, the low-worth / context-heavy / cost-outlier / capability-reliability detectors, the coaching notes, the file-churn table, and the model-default recommendation now all run on user-started sessions only, and the raw read/edit, junk-read and duplicate-read detectors skip calls made inside a sidechain transcript. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total, in `status`, and in the configuration-overhead findings, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. From 595225da34229c71b94e0e4a9bfb1b1d71700b5b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 09:27:21 -0700 Subject: [PATCH 12/13] changelog: note the one-time lifetime jump when retained history reappears (#987) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3dfbad..7663da93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed -- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) +- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading. - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. From 29b531fced1d1b61ff133412aeedfb8c849d1add Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 09:38:11 -0700 Subject: [PATCH 13/13] optimize: keep sidechain tool calls in the junk-read and read:edit signals Only duplicate-reads has a structural reason to skip them: a subagent starts on a fresh context, so re-reading what its parent read is a necessary read, not a repeat. Reading node_modules or editing without reading is the same waste whoever does it, and the CLAUDE.md rule both findings suggest binds subagents too - filtering them there discarded most of the evidence on a subagent-heavy corpus. --- CHANGELOG.md | 2 +- README.md | 10 ++++++---- src/optimize.ts | 6 ++++-- tests/optimize-fs.test.ts | 21 ++++++++++++--------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ad4d90..9a999aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed -- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). The session count, the low-worth / context-heavy / cost-outlier / capability-reliability detectors, the coaching notes, the file-churn table, and the model-default recommendation now all run on user-started sessions only, and the raw read/edit, junk-read and duplicate-read detectors skip calls made inside a sidechain transcript. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total, in `status`, and in the configuration-overhead findings, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) +- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. diff --git a/README.md b/README.md index 26a7e67c..e8059461 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,13 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: -For Claude Code, the optimize session count, behavioral findings, coaching, and -model-default recommendations use user-started (main) sessions. Subagent +For Claude Code, the optimize session count, the per-session findings, coaching, +and model-default recommendations use user-started (main) sessions. Subagent sidechain transcripts are excluded from that population because their delegated -context and delivery behavior are structurally different; their tokens, calls, -and cost still count in all spend totals and configuration-overhead findings. +context and delivery behavior are structurally different, and so is the re-read +finding, since a subagent starts on a fresh context. Findings about how Claude +uses tools (junk reads, read:edit ratio) and every spend, MCP, and +configuration-overhead finding keep counting them. - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) diff --git a/src/optimize.ts b/src/optimize.ts index 2cd7d2eb..5e3ff1ee 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -958,7 +958,6 @@ export function localMcpServerNames(projectCwds: Iterable, homeDir = hom // ============================================================================ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { - calls = calls.filter(call => call.isSidechain !== true) const dirCounts = new Map() let totalJunkReads = 0 let recentJunkReads = 0 @@ -1009,6 +1008,10 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste } export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + // A sidechain re-reading what its parent read is not a repeat: a subagent + // starts on a fresh context and has to read it. Junk reads and the + // read:edit ratio keep the full call population - that waste is waste + // whoever does it, and the CLAUDE.md rule they suggest binds subagents too. calls = calls.filter(call => call.isSidechain !== true) const sessionFiles = new Map>() @@ -2618,7 +2621,6 @@ export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWr export const BASH_TOOL_NAMES = new Set(['Bash', 'BashTool', 'PowerShellTool']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { - calls = calls.filter(call => call.isSidechain !== true) let reads = 0 let edits = 0 let recentEdits = 0 diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 86b1cb13..60b64b4f 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -380,22 +380,25 @@ describe('scanJsonlFile', () => { expect(result.userMessages).toEqual(['delegate this']) }) - it('excludes marked sidechain calls from raw human-behavior detectors', () => { + it('keeps sidechain calls out of duplicate reads but in junk reads and the read:edit ratio', () => { + const sidechain = { sessionId: 'agent-reviewer', project: 'p1', isSidechain: true } const editCalls = Array.from({ length: 10 }, (_, index) => ({ - name: 'Edit', input: { file_path: `/src/${index}.ts` }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Edit', input: { file_path: `/src/${index}.ts` }, ...sidechain, })) const junkReads = Array.from({ length: 6 }, () => ({ - name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, ...sidechain, })) const repeatReads = Array.from({ length: 6 }, () => ({ - name: 'Read', input: { file_path: '/app/src/a.ts' }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Read', input: { file_path: '/app/src/a.ts' }, ...sidechain, })) - expect(detectLowReadEditRatio(editCalls)).toBeNull() - expect(detectJunkReads(junkReads)).toBeNull() + // A subagent editing without reading, or reading into node_modules, is the + // same waste as the parent doing it, and the CLAUDE.md rule both suggest + // binds subagents too - so the full call population feeds them. + expect(detectLowReadEditRatio(editCalls)?.id).toBe('read-edit-ratio') + expect(detectJunkReads(junkReads)?.id).toBe('build-folder-reads') + // A re-read is only waste when the context already held the file; a + // sidechain starts fresh and has to read it. expect(detectDuplicateReads(repeatReads)).toBeNull() })