From 1aa6ed6e5aa1bd16bbc78caf34025410b94fb211 Mon Sep 17 00:00:00 2001 From: barry <91018388+barry166@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:53:49 +0800 Subject: [PATCH] Add JSON output for optimize and yield Dashboard integrations need machine-readable optimize findings and yield ratios without parsing terminal output. This threads the existing analysis results into conservative JSON serializers while preserving text output as the default. Constraint: Issue #419 asks for dashboard-friendly JSON for optimize and yield Rejected: Build a separate analysis path | would risk drift from terminal output Confidence: high Scope-risk: narrow Tested: npm test Tested: npm run build Tested: npm run dev -- optimize -p today --format json Tested: npm run dev -- yield -p today --format json Not-tested: Real downstream dashboard integration --- README.md | 4 +- src/main.ts | 16 ++++++-- src/optimize.ts | 90 ++++++++++++++++++++++++++++++++++++++++-- src/yield.ts | 71 +++++++++++++++++++++++++++++++++ tests/optimize.test.ts | 67 +++++++++++++++++++++++++++++++ tests/yield.test.ts | 58 +++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 tests/yield.test.ts diff --git a/README.md b/README.md index 1f9aa844..111632a4 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ codeburn optimize # scan the last 30 days codeburn optimize -p today # today only codeburn optimize -p week # last 7 days codeburn optimize --provider claude # restrict to one provider +codeburn optimize --format json # setup health + findings as JSON ``` Scans your sessions and your `~/.claude/` setup for waste patterns: @@ -255,6 +256,7 @@ codeburn yield # last 7 days (default) codeburn yield -p today # today only codeburn yield -p 30days # last 30 days codeburn yield -p month # this calendar month +codeburn yield --format json # productive/reverted/abandoned spend as JSON ``` Correlates AI sessions with git commits by timestamp: @@ -345,7 +347,7 @@ codeburn report --format json | jq '.projects' codeburn today --format json | jq '.overview.cost' ``` -For lighter output, use `status --format json` (today and month totals only) or file exports (`export -f json`). +For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned spend), or file exports (`export -f json`). ## Menu Bar diff --git a/src/main.ts b/src/main.ts index a81f7567..1fad67b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1033,11 +1033,13 @@ program .description('Find token waste and get exact fixes') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: text, json', 'text') .action(async (opts) => { + assertFormat(opts.format, ['text', 'json'], 'optimize') await loadPricing() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) - await runOptimize(projects, label, range) + await runOptimize(projects, label, range, { format: opts.format }) }) program @@ -1111,12 +1113,20 @@ program .command('yield') .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') + .option('--format ', 'Output format: text, json', 'text') .action(async (opts) => { - const { computeYield, formatYieldSummary } = await import('./yield.js') + assertFormat(opts.format, ['text', 'json'], 'yield') + const { computeYield, formatYieldSummary, buildYieldJsonReport } = await import('./yield.js') await loadPricing() const { range, label } = getDateRange(opts.period) - console.log(`\n Analyzing yield for ${label}...\n`) + if (opts.format !== 'json') { + console.log(`\n Analyzing yield for ${label}...\n`) + } const summary = await computeYield(range, process.cwd()) + if (opts.format === 'json') { + console.log(JSON.stringify(buildYieldJsonReport(summary, label, range), null, 2)) + return + } console.log(formatYieldSummary(summary)) }) diff --git a/src/optimize.ts b/src/optimize.ts index cce6b880..43925fe2 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -202,6 +202,35 @@ export type OptimizeResult = { healthGrade: HealthGrade } +export type OptimizeJsonReport = { + period: { + label: string + start: string | null + end: string | null + } + summary: { + healthScore: number + healthGrade: HealthGrade + findingCount: number + periodCostUSD: number + sessions: number + calls: number + potentialSavingsTokens: number + potentialSavingsCostUSD: number + potentialSavingsPercent: number | null + costRateUSD: number + } + findings: Array<{ + title: string + explanation: string + severity: Impact + trend: Trend | null + tokensSaved: number + estimatedSavingsUSD: number + fix: WasteAction + }> +} + export type ToolCall = { name: string input: Record @@ -2476,19 +2505,74 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, + opts: { format?: 'text' | 'json' } = {}, ): Promise { - if (projects.length === 0) { + const format = opts.format ?? 'text' + if (projects.length === 0 && format === 'text') { console.log(chalk.dim('\n No usage data found for this period.\n')) return } - process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + if (format === 'text') { + process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + } - const { findings, costRate, healthScore, healthGrade } = await scanAndDetect(projects, dateRange) + const result = await scanAndDetect(projects, dateRange) + const { findings, costRate, healthScore, healthGrade } = result const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) + if (format === 'json') { + console.log(JSON.stringify(buildOptimizeJsonReport(projects, periodLabel, result, dateRange), null, 2)) + return + } + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade) console.log(output) } + +export function buildOptimizeJsonReport( + projects: ProjectSummary[], + periodLabel: string, + 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) + const potentialSavingsCostUSD = potentialSavingsTokens * result.costRate + const potentialSavingsPercent = periodCostUSD > 0 + ? Math.round((potentialSavingsCostUSD / periodCostUSD) * 1000) / 10 + : null + + return { + period: { + label: periodLabel, + start: dateRange?.start.toISOString() ?? null, + end: dateRange?.end.toISOString() ?? null, + }, + summary: { + healthScore: result.healthScore, + healthGrade: result.healthGrade, + findingCount: result.findings.length, + periodCostUSD, + sessions: sessions.length, + calls, + potentialSavingsTokens, + potentialSavingsCostUSD, + potentialSavingsPercent, + costRateUSD: result.costRate, + }, + findings: result.findings.map(f => ({ + title: f.title, + explanation: f.explanation, + severity: f.impact, + trend: f.trend ?? null, + tokensSaved: f.tokensSaved, + estimatedSavingsUSD: f.tokensSaved * result.costRate, + fix: f.fix, + })), + } +} diff --git a/src/yield.ts b/src/yield.ts index c26a18f3..aefd9284 100644 --- a/src/yield.ts +++ b/src/yield.ts @@ -20,6 +20,33 @@ export type YieldSummary = { details: SessionYield[] } +export type YieldJsonReport = { + period: { + label: string + start: string + end: string + } + summary: { + productive: YieldBucketJson + reverted: YieldBucketJson + abandoned: YieldBucketJson + total: { costUSD: number; sessions: number } + productiveToRevertedCostRatio: number | null + } + details: SessionYieldJson[] +} + +type YieldBucketJson = { + costUSD: number + sessions: number + costPercent: number + sessionPercent: number +} + +type SessionYieldJson = Omit & { + costUSD: number +} + const SAFE_REF_PATTERN = /^[A-Za-z0-9._/\-]+$/ function runGit(args: string[], cwd: string): string | null { @@ -216,3 +243,47 @@ export function formatYieldSummary(summary: YieldSummary): string { return lines.join('\n') } + +export function buildYieldJsonReport( + summary: YieldSummary, + periodLabel: string, + range: DateRange, +): YieldJsonReport { + const bucket = (value: { cost: number; sessions: number }): YieldBucketJson => ({ + costUSD: value.cost, + sessions: value.sessions, + costPercent: summary.total.cost > 0 + ? Math.round((value.cost / summary.total.cost) * 1000) / 10 + : 0, + sessionPercent: summary.total.sessions > 0 + ? Math.round((value.sessions / summary.total.sessions) * 1000) / 10 + : 0, + }) + + return { + period: { + label: periodLabel, + start: range.start.toISOString(), + end: range.end.toISOString(), + }, + summary: { + productive: bucket(summary.productive), + reverted: bucket(summary.reverted), + abandoned: bucket(summary.abandoned), + total: { + costUSD: summary.total.cost, + sessions: summary.total.sessions, + }, + productiveToRevertedCostRatio: summary.reverted.cost > 0 + ? Math.round((summary.productive.cost / summary.reverted.cost) * 100) / 100 + : null, + }, + details: summary.details.map(detail => ({ + sessionId: detail.sessionId, + project: detail.project, + costUSD: detail.cost, + category: detail.category, + commitCount: detail.commitCount, + })), + } +} diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index 8af86398..352cf3c9 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -12,9 +12,11 @@ import { detectSessionOutliers, computeHealth, computeTrend, + buildOptimizeJsonReport, type ToolCall, type ApiCallMeta, type WasteFinding, + type OptimizeResult, } from '../src/optimize.js' import type { ProjectSummary } from '../src/types.js' @@ -1115,3 +1117,68 @@ describe('paste-fix destination tagging (issue #277)', () => { if (finding) checkAllPasteFixesHaveDestination([finding]) }) }) + +describe('buildOptimizeJsonReport', () => { + it('serializes setup health, savings, and fix details for integrations', () => { + const result: OptimizeResult = { + costRate: 0.00002, + healthScore: 72, + healthGrade: 'C', + findings: [ + { + title: 'Trim stale context', + explanation: 'Old instructions are loaded every turn.', + impact: 'medium', + tokensSaved: 50_000, + trend: 'active', + fix: { + type: 'paste', + label: 'Add guardrail', + text: 'Prefer short context.', + destination: 'claude-md', + }, + }, + ], + } + const range = { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-05-08T00:00:00.000Z'), + } + + const report = buildOptimizeJsonReport( + [projectWithSessions([3, 2])], + '7 Days', + result, + range, + ) + + expect(report.period).toEqual({ + label: '7 Days', + start: '2026-05-01T00:00:00.000Z', + end: '2026-05-08T00:00:00.000Z', + }) + expect(report.summary).toMatchObject({ + healthScore: 72, + healthGrade: 'C', + findingCount: 1, + periodCostUSD: 5, + sessions: 2, + calls: 2, + potentialSavingsTokens: 50_000, + potentialSavingsCostUSD: 1, + potentialSavingsPercent: 20, + costRateUSD: 0.00002, + }) + expect(report.findings[0]).toMatchObject({ + title: 'Trim stale context', + severity: 'medium', + trend: 'active', + tokensSaved: 50_000, + estimatedSavingsUSD: 1, + fix: { + type: 'paste', + destination: 'claude-md', + }, + }) + }) +}) diff --git a/tests/yield.test.ts b/tests/yield.test.ts new file mode 100644 index 00000000..8a6f31d3 --- /dev/null +++ b/tests/yield.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { buildYieldJsonReport, type YieldSummary } from '../src/yield.js' + +describe('buildYieldJsonReport', () => { + it('serializes yield buckets, ratios, and session details', () => { + const summary: YieldSummary = { + productive: { cost: 8, sessions: 2 }, + reverted: { cost: 2, sessions: 1 }, + abandoned: { cost: 10, sessions: 1 }, + total: { cost: 20, sessions: 4 }, + details: [ + { sessionId: 's1', project: 'app', cost: 8, category: 'productive', commitCount: 2 }, + { sessionId: 's2', project: 'app', cost: 2, category: 'reverted', commitCount: 1 }, + ], + } + const report = buildYieldJsonReport(summary, '30 Days', { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-05-31T23:59:59.999Z'), + }) + + expect(report.period).toEqual({ + label: '30 Days', + start: '2026-05-01T00:00:00.000Z', + end: '2026-05-31T23:59:59.999Z', + }) + expect(report.summary.productive).toEqual({ + costUSD: 8, + sessions: 2, + costPercent: 40, + sessionPercent: 50, + }) + expect(report.summary.reverted.costPercent).toBe(10) + expect(report.summary.abandoned.sessionPercent).toBe(25) + expect(report.summary.total).toEqual({ costUSD: 20, sessions: 4 }) + expect(report.summary.productiveToRevertedCostRatio).toBe(4) + expect(report.details).toHaveLength(2) + expect(report.details[0]).toMatchObject({ sessionId: 's1', costUSD: 8, category: 'productive' }) + expect(report.details[0]).not.toHaveProperty('cost') + }) + + it('uses null ratio when no spend was reverted', () => { + const summary: YieldSummary = { + productive: { cost: 1, sessions: 1 }, + reverted: { cost: 0, sessions: 0 }, + abandoned: { cost: 0, sessions: 0 }, + total: { cost: 1, sessions: 1 }, + details: [], + } + + const report = buildYieldJsonReport(summary, 'Today', { + start: new Date('2026-06-14T00:00:00.000Z'), + end: new Date('2026-06-14T23:59:59.999Z'), + }) + + expect(report.summary.productiveToRevertedCostRatio).toBeNull() + }) +})