feat(cli): add burn guardrails

This commit is contained in:
ozymandiashh 2026-05-06 01:15:54 +03:00
parent 869474b3b4
commit 660675a051
5 changed files with 561 additions and 0 deletions

View file

@ -3,6 +3,10 @@
## Unreleased
### Added (CLI)
- **Burn Guard.** New `codeburn guard` command warns when AI coding spend
reaches configurable per-session or per-local-hour USD thresholds. Supports
period/date range/provider/project filters, `--json` for scripts, and
`--fail-on-alert` for cron/CI guardrail workflows.
- **MCP tool coverage detector.** New `optimize` finding flags MCP servers
whose tool inventory is largely unused. Inventory is observed from the
Claude `deferred_tools_delta` JSONL attachments (exact tool names per

View file

@ -78,6 +78,8 @@ codeburn status # compact one-liner (today + month)
codeburn status --format json
codeburn export # CSV with today, 7 days, 30 days
codeburn export -f json # JSON export
codeburn guard # warn when today's spend crosses guardrails
codeburn guard --max-session-usd 3 --max-hourly-usd 10
codeburn optimize # find waste, get copy-paste fixes
codeburn optimize -p week # scope the scan to last 7 days
codeburn compare # side-by-side model comparison
@ -138,6 +140,18 @@ Adding a new provider is a single file. See `src/providers/codex.ts` for an exam
Prices every API call using input, output, cache read, cache write, and web search token counts. Fast mode multiplier for Claude. Pricing fetched from [LiteLLM](https://github.com/BerriAI/litellm) and cached locally for 24 hours. Hardcoded fallbacks for all Claude and GPT models to prevent mispricing.
### Burn Guard
Run a quick local guardrail check before spend becomes a surprise:
```bash
codeburn guard
codeburn guard -p week --max-session-usd 5 --max-hourly-usd 20
codeburn guard --json --fail-on-alert
```
The guard command flags sessions or local-hour windows that reach configurable USD thresholds. It supports the same provider, project, exclude, period, `--from`, and `--to` filters as the reporting commands, so it can be used manually, from cron, or in shell scripts.
### Task Categories
13 categories classified from tool usage patterns and user message keywords. No LLM calls, fully deterministic.

View file

@ -14,6 +14,7 @@ import { renderDashboard } from './dashboard.js'
import { parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { analyzeGuard, DEFAULT_MAX_HOURLY_USD, DEFAULT_MAX_SESSION_USD, renderGuardText } from './guard.js'
import { getAllProviders } from './providers/index.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
@ -48,6 +49,13 @@ function parseInteger(value: string): number {
return parseInt(value, 10)
}
function validatePositiveNumber(name: string, value: number): boolean {
if (Number.isFinite(value) && value > 0) return true
console.error(`\n ${name} must be a positive number; got ${value}.\n`)
process.exitCode = 1
return false
}
type JsonPlanSummary = {
id: PlanId
budget: number
@ -572,6 +580,64 @@ program
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
})
program
.command('guard')
.description('Warn when AI coding spend crosses session or hourly guardrails')
.option('-p, --period <period>', 'Guard period: today, week, 30days, month, all', 'today')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--max-session-usd <amount>', 'Alert when one session is at or above this USD cost', parseNumber, DEFAULT_MAX_SESSION_USD)
.option('--max-hourly-usd <amount>', 'Alert when one local hour is at or above this USD cost', parseNumber, DEFAULT_MAX_HOURLY_USD)
.option('--json', 'Print machine-readable JSON')
.option('--fail-on-alert', 'Exit with status 1 when guard alerts are found')
.action(async (opts) => {
const sessionThresholdOk = validatePositiveNumber('--max-session-usd', opts.maxSessionUsd)
const hourlyThresholdOk = validatePositiveNumber('--max-hourly-usd', opts.maxHourlyUsd)
if (!sessionThresholdOk || !hourlyThresholdOk) {
return
}
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exitCode = 1
return
}
await loadPricing()
const period = toPeriod(opts.period)
const { range, label } = customRange
? { range: customRange, label: `${toDateString(customRange.start)} to ${toDateString(customRange.end)}` }
: getDateRange(period)
const projects = filterProjectsByName(
await parseAllSessions(range, opts.provider),
opts.project,
opts.exclude,
)
const result = analyzeGuard(projects, {
label,
maxSessionUSD: opts.maxSessionUsd,
maxHourlyUSD: opts.maxHourlyUsd,
})
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(renderGuardText(result))
}
if (opts.failOnAlert && result.alerts.length > 0) {
process.exitCode = 1
}
})
program
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')

253
src/guard.ts Normal file
View file

@ -0,0 +1,253 @@
import chalk from 'chalk'
import { formatCost } from './currency.js'
import { toDateString } from './daily-cache.js'
import type { ProjectSummary } from './types.js'
export const DEFAULT_MAX_SESSION_USD = 3
export const DEFAULT_MAX_HOURLY_USD = 10
const PANEL_WIDTH = 72
const SEP = '-'
const RED = '#ff6b6b'
const GREEN = '#7bd88f'
const GOLD = '#ffd166'
const ORANGE = '#ff9f1c'
const DIM = '#6b7280'
export type GuardThresholds = {
maxSessionUSD: number
maxHourlyUSD: number
}
export type GuardSessionAlert = {
type: 'session'
project: string
sessionId: string
costUSD: number
limitUSD: number
apiCalls: number
firstTimestamp: string
lastTimestamp: string
}
export type GuardHourlyAlert = {
type: 'hour'
hour: string
costUSD: number
limitUSD: number
apiCalls: number
projects: string[]
sessions: string[]
}
export type GuardAlert = GuardSessionAlert | GuardHourlyAlert
export type GuardSummary = {
projects: number
sessions: number
apiCalls: number
totalCostUSD: number
}
export type GuardResult = {
label: string
thresholds: GuardThresholds
summary: GuardSummary
alerts: GuardAlert[]
}
export type AnalyzeGuardOptions = {
label: string
maxSessionUSD: number
maxHourlyUSD: number
}
type HourBucket = {
costUSD: number
apiCalls: number
projects: Set<string>
sessions: Set<string>
}
function parseTimestamp(timestamp: string | undefined): Date | null {
if (!timestamp) return null
const date = new Date(timestamp)
return Number.isNaN(date.getTime()) ? null : date
}
function localHourLabel(timestamp: string | undefined): string | null {
const date = parseTimestamp(timestamp)
if (!date) return null
return `${toDateString(date)} ${String(date.getHours()).padStart(2, '0')}:00`
}
function isPositiveCost(costUSD: number): boolean {
return Number.isFinite(costUSD) && costUSD > 0
}
function severity(alert: GuardAlert): number {
return alert.limitUSD > 0 ? alert.costUSD / alert.limitUSD : alert.costUSD
}
export function analyzeGuard(projects: ProjectSummary[], options: AnalyzeGuardOptions): GuardResult {
const thresholds: GuardThresholds = {
maxSessionUSD: options.maxSessionUSD,
maxHourlyUSD: options.maxHourlyUSD,
}
const alerts: GuardAlert[] = []
const hourly = new Map<string, HourBucket>()
let sessions = 0
let apiCalls = 0
let totalCostUSD = 0
for (const project of projects) {
totalCostUSD += project.totalCostUSD
apiCalls += project.totalApiCalls
sessions += project.sessions.length
for (const session of project.sessions) {
if (session.totalCostUSD >= thresholds.maxSessionUSD) {
alerts.push({
type: 'session',
project: project.project,
sessionId: session.sessionId,
costUSD: session.totalCostUSD,
limitUSD: thresholds.maxSessionUSD,
apiCalls: session.apiCalls,
firstTimestamp: session.firstTimestamp,
lastTimestamp: session.lastTimestamp,
})
}
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
if (!isPositiveCost(call.costUSD)) continue
const hour = localHourLabel(call.timestamp) ?? localHourLabel(turn.timestamp)
if (!hour) continue
const bucket = hourly.get(hour) ?? {
costUSD: 0,
apiCalls: 0,
projects: new Set<string>(),
sessions: new Set<string>(),
}
bucket.costUSD += call.costUSD
bucket.apiCalls += 1
bucket.projects.add(project.project)
bucket.sessions.add(session.sessionId)
hourly.set(hour, bucket)
}
}
}
}
for (const [hour, bucket] of hourly) {
if (bucket.costUSD < thresholds.maxHourlyUSD) continue
alerts.push({
type: 'hour',
hour,
costUSD: bucket.costUSD,
limitUSD: thresholds.maxHourlyUSD,
apiCalls: bucket.apiCalls,
projects: [...bucket.projects].sort(),
sessions: [...bucket.sessions].sort(),
})
}
alerts.sort((a, b) => {
const bySeverity = severity(b) - severity(a)
if (bySeverity !== 0) return bySeverity
return b.costUSD - a.costUSD
})
return {
label: options.label,
thresholds,
summary: {
projects: projects.length,
sessions,
apiCalls,
totalCostUSD,
},
alerts,
}
}
function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
return `${count} ${count === 1 ? singular : pluralForm}`
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, Math.max(0, maxLength - 3))}...`
}
function renderAlertLine(alert: GuardAlert): string {
if (alert.type === 'session') {
const target = truncate(`${alert.project}/${alert.sessionId}`, 42)
return [
chalk.hex(RED)('Session'),
chalk.bold(target),
chalk.hex(GOLD)(formatCost(alert.costUSD)),
chalk.dim(`limit ${formatCost(alert.limitUSD)}`),
chalk.dim(`${plural(alert.apiCalls, 'call')}`),
].join(' ')
}
const projects = alert.projects.length > 0 ? alert.projects.join(', ') : 'unknown project'
return [
chalk.hex(RED)('Hour'),
chalk.bold(alert.hour),
chalk.hex(GOLD)(formatCost(alert.costUSD)),
chalk.dim(`limit ${formatCost(alert.limitUSD)}`),
chalk.dim(`${plural(alert.apiCalls, 'call')} across ${truncate(projects, 30)}`),
].join(' ')
}
export function renderGuardText(result: GuardResult): string {
const lines: string[] = []
lines.push('')
lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn guard')}${chalk.dim(' ' + result.label)}`)
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
lines.push(' ' + [
plural(result.summary.projects, 'project'),
plural(result.summary.sessions, 'session'),
plural(result.summary.apiCalls, 'call'),
chalk.hex(GOLD)(formatCost(result.summary.totalCostUSD)),
].join(chalk.hex(DIM)(' ')))
lines.push(' ' + chalk.dim([
`session limit ${formatCost(result.thresholds.maxSessionUSD)}`,
`hourly limit ${formatCost(result.thresholds.maxHourlyUSD)}`,
].join(' ')))
lines.push('')
if (result.summary.sessions === 0) {
lines.push(chalk.dim(' No usage data found for this period.'))
lines.push('')
return lines.join('\n')
}
if (result.alerts.length === 0) {
lines.push(chalk.hex(GREEN)(' Guard OK. No spend guardrails crossed.'))
lines.push('')
return lines.join('\n')
}
lines.push(chalk.hex(RED)(` ${plural(result.alerts.length, 'alert')} crossed configured guardrails.`))
lines.push('')
const visibleAlerts = result.alerts.slice(0, 10)
for (const alert of visibleAlerts) {
lines.push(` ${renderAlertLine(alert)}`)
}
const hidden = result.alerts.length - visibleAlerts.length
if (hidden > 0) {
lines.push(chalk.dim(` ...and ${plural(hidden, 'more alert')}.`))
}
lines.push('')
return lines.join('\n')
}

224
tests/guard.test.ts Normal file
View file

@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { analyzeGuard, renderGuardText } from '../src/guard.js'
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
function makeCall(costUSD: number, timestamp: string): ParsedApiCall {
return {
provider: 'claude',
model: 'claude-sonnet-4-5',
usage: {
inputTokens: 1000,
outputTokens: 500,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `${timestamp}:${costUSD}`,
}
}
function makeTurn(sessionId: string, timestamp: string, calls: ParsedApiCall[]): ClassifiedTurn {
return {
userMessage: 'test',
assistantCalls: calls,
timestamp,
sessionId,
category: 'coding',
retries: 0,
hasEdits: true,
}
}
function makeSession(
project: string,
sessionId: string,
totalCostUSD: number,
timestamp: string,
turns: ClassifiedTurn[] = [],
): SessionSummary {
const apiCalls = turns.reduce((sum, turn) => sum + turn.assistantCalls.length, 0)
return {
sessionId,
project,
firstTimestamp: timestamp,
lastTimestamp: timestamp,
totalCostUSD,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls,
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
}
}
function makeProject(project: string, sessions: SessionSummary[]): ProjectSummary {
return {
project,
projectPath: `/tmp/${project}`,
sessions,
totalCostUSD: sessions.reduce((sum, session) => sum + session.totalCostUSD, 0),
totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0),
}
}
describe('analyzeGuard', () => {
it('returns no alerts when spend stays below configured guardrails', () => {
const result = analyzeGuard([
makeProject('app', [
makeSession('app', 's1', 1.25, '2026-05-05T10:00:00Z', [
makeTurn('s1', '2026-05-05T10:00:00Z', [makeCall(1.25, '2026-05-05T10:00:00Z')]),
]),
]),
], {
label: 'Today',
maxSessionUSD: 3,
maxHourlyUSD: 10,
})
expect(result.summary).toMatchObject({
projects: 1,
sessions: 1,
apiCalls: 1,
totalCostUSD: 1.25,
})
expect(result.alerts).toEqual([])
})
it('flags sessions that reach the max session threshold', () => {
const result = analyzeGuard([
makeProject('api', [
makeSession('api', 'expensive-session', 3, '2026-05-05T11:00:00Z'),
]),
], {
label: 'Today',
maxSessionUSD: 3,
maxHourlyUSD: 10,
})
expect(result.alerts).toEqual([
expect.objectContaining({
type: 'session',
project: 'api',
sessionId: 'expensive-session',
costUSD: 3,
limitUSD: 3,
}),
])
})
it('aggregates hourly spend across sessions and projects', () => {
const sessionA = makeSession('api', 's1', 4, '2026-05-05T10:05:00Z', [
makeTurn('s1', '2026-05-05T10:05:00Z', [makeCall(4, '2026-05-05T10:05:00Z')]),
])
const sessionB = makeSession('web', 's2', 6, '2026-05-05T10:05:30Z', [
makeTurn('s2', '2026-05-05T10:05:30Z', [makeCall(6, '2026-05-05T10:05:30Z')]),
])
const result = analyzeGuard([
makeProject('api', [sessionA]),
makeProject('web', [sessionB]),
], {
label: 'Today',
maxSessionUSD: 20,
maxHourlyUSD: 10,
})
expect(result.alerts).toEqual([
expect.objectContaining({
type: 'hour',
costUSD: 10,
limitUSD: 10,
apiCalls: 2,
projects: ['api', 'web'],
sessions: ['s1', 's2'],
}),
])
})
it('ignores invalid timestamps for hourly guardrails', () => {
const result = analyzeGuard([
makeProject('api', [
makeSession('api', 's1', 20, '2026-05-05T10:00:00Z', [
makeTurn('s1', 'not-a-date', [makeCall(20, 'not-a-date')]),
]),
]),
], {
label: 'Today',
maxSessionUSD: 100,
maxHourlyUSD: 10,
})
expect(result.alerts).toEqual([])
})
it('sorts alerts by threshold severity before raw cost', () => {
const expensiveSession = makeSession('api', 's1', 12, '2026-05-05T09:00:00Z')
const hourlySessionA = makeSession('web', 's2', 4, '2026-05-05T10:05:00Z', [
makeTurn('s2', '2026-05-05T10:05:00Z', [makeCall(4, '2026-05-05T10:05:00Z')]),
])
const hourlySessionB = makeSession('web', 's3', 5, '2026-05-05T10:05:30Z', [
makeTurn('s3', '2026-05-05T10:05:30Z', [makeCall(5, '2026-05-05T10:05:30Z')]),
])
const result = analyzeGuard([
makeProject('api', [expensiveSession]),
makeProject('web', [hourlySessionA, hourlySessionB]),
], {
label: 'Today',
maxSessionUSD: 6,
maxHourlyUSD: 8,
})
expect(result.alerts).toHaveLength(2)
expect(result.alerts[0]).toMatchObject({ type: 'session', sessionId: 's1', costUSD: 12 })
expect(result.alerts[1]).toMatchObject({ type: 'hour', costUSD: 9 })
})
})
describe('renderGuardText', () => {
it('shows an empty-period message when no sessions are present', () => {
const result = analyzeGuard([], {
label: 'Today',
maxSessionUSD: 3,
maxHourlyUSD: 10,
})
expect(renderGuardText(result)).toContain('No usage data found for this period.')
})
it('renders session alerts with the target and limit', () => {
const result = analyzeGuard([
makeProject('api', [
makeSession('api', 'expensive-session', 3, '2026-05-05T11:00:00Z'),
]),
], {
label: 'Today',
maxSessionUSD: 3,
maxHourlyUSD: 10,
})
const text = renderGuardText(result)
expect(text).toContain('Session')
expect(text).toContain('api/expensive-session')
expect(text).toContain('limit $3.00')
})
})