mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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
This commit is contained in:
parent
49d1ddc376
commit
a2947a48be
6 changed files with 299 additions and 7 deletions
|
|
@ -210,6 +210,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:
|
||||
|
|
@ -259,6 +260,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:
|
||||
|
|
@ -349,7 +351,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
|
||||
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -1033,11 +1033,13 @@ program
|
|||
.description('Find token waste and get exact fixes')
|
||||
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: text, json', 'text')
|
||||
.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 <period>', 'Analysis period: today, week, 30days, month, all', 'week')
|
||||
.option('--format <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))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
|
|
@ -2476,19 +2505,74 @@ export async function runOptimize(
|
|||
projects: ProjectSummary[],
|
||||
periodLabel: string,
|
||||
dateRange?: DateRange,
|
||||
opts: { format?: 'text' | 'json' } = {},
|
||||
): Promise<void> {
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
src/yield.ts
71
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<SessionYield, 'cost'> & {
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
58
tests/yield.test.ts
Normal file
58
tests/yield.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue