mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 21:04:57 +00:00
Capture a trailing-14-day before-baseline when a fix is applied and re-measure it against the post-apply window so optimize can show realized numbers next to estimates. - ActionBaseline (windowDays, capturedAt, estimatedTokens, sessions, metrics) persisted by runAction; captured in the optimize --apply flow and at guard install time. - codeburn act report [--json]: applied, not-undone actions older than 3 days, re-running the detectors over apply-date-to-now (capped 30 days). Per-kind realized deltas: MCP/archive tokens-per-session times saved sessions with reverted-by-user detection; read-edit deficit reduction; guard yield split labeled correlation. Bash cap is marked not measurable (result sizes are not retained). Low confidence under 20 post-window sessions or past a 2x volume shift. Realized numbers rounded down, estimate kept visible. - optimize gains one header line only when a measured action exists, and appends "(previously applied <date>, re-flagged)" to re-triggered findings. No change for users with no applied actions. Reuses scanAndDetect helpers over a date-bounded range; exports the token constants and read/edit tool sets rather than duplicating the math.
229 lines
9 KiB
TypeScript
229 lines
9 KiB
TypeScript
import type { Command } from 'commander'
|
|
import { readdir, stat, readFile } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { join } from 'path'
|
|
import chalk from 'chalk'
|
|
|
|
type Scope = { global?: boolean; project?: string }
|
|
|
|
function readStdin(): Promise<string> {
|
|
return new Promise(resolve => {
|
|
const stdin = process.stdin
|
|
if (stdin.isTTY) { resolve(''); return }
|
|
let data = ''
|
|
stdin.setEncoding('utf-8')
|
|
stdin.on('data', c => { data += c })
|
|
stdin.on('end', () => resolve(data))
|
|
stdin.on('error', () => resolve(''))
|
|
})
|
|
}
|
|
|
|
function usd(n: number | null): string {
|
|
return n === null ? 'off' : `$${n}`
|
|
}
|
|
|
|
async function refreshFlags(): Promise<number> {
|
|
const { parseAllSessions } = await import('../parser.js')
|
|
const { buildFlags, writeFlags } = await import('./flags.js')
|
|
const projects = await parseAllSessions()
|
|
const flags = await buildFlags(projects)
|
|
await writeFlags(flags)
|
|
return flags.projects.length
|
|
}
|
|
|
|
async function doInstall(scope: Scope, statusline: boolean): Promise<void> {
|
|
const { settingsPathFor, buildInstall } = await import('./settings.js')
|
|
const { runAction } = await import('../act/apply.js')
|
|
const { shortId } = await import('../act/journal.js')
|
|
const { readGuardConfig, writeGuardConfig, guardConfigPath, DEFAULT_GUARD_CONFIG } = await import('./store.js')
|
|
|
|
const path = settingsPathFor({ ...scope, cwd: process.cwd() })
|
|
const built = buildInstall(path, { statusline })
|
|
for (const note of built.notes) console.log(chalk.yellow(` ! ${note}`))
|
|
if (built.plan) {
|
|
// Capture a trailing-14-day yield baseline so `act report` can correlate
|
|
// the guard install against later yield. Best effort; a failure just
|
|
// leaves the guard row "not measurable".
|
|
try {
|
|
const { captureGuardBaseline } = await import('../act/report.js')
|
|
const baseline = await captureGuardBaseline({ cwd: process.cwd() })
|
|
if (baseline) built.plan.baseline = baseline
|
|
} catch { /* baseline is optional */ }
|
|
const record = await runAction(built.plan)
|
|
console.log(` Installed ${chalk.bold(shortId(record.id))} ${built.plan.description}`)
|
|
console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))
|
|
} else {
|
|
console.log(chalk.dim(` ${path}: nothing to change.`))
|
|
}
|
|
|
|
if (!existsSync(guardConfigPath())) {
|
|
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, updatedAt: new Date().toISOString() })
|
|
const c = await readGuardConfig()
|
|
console.log(chalk.dim(` Wrote guard.json (soft ${usd(c.softUSD)}, hard ${usd(c.hardUSD)}, checkpoint ${usd(c.checkpointUSD)}).`))
|
|
}
|
|
|
|
try {
|
|
const flagged = await refreshFlags()
|
|
console.log(chalk.dim(` Flagged ${flagged} project${flagged === 1 ? '' : 's'} for session openers.`))
|
|
} catch (e) {
|
|
console.log(chalk.yellow(` ! could not compute session-opener flags: ${e instanceof Error ? e.message : String(e)}`))
|
|
}
|
|
}
|
|
|
|
async function doUninstall(scope: Scope): Promise<void> {
|
|
const { settingsPathFor, buildUninstall } = await import('./settings.js')
|
|
const { runAction } = await import('../act/apply.js')
|
|
const { shortId } = await import('../act/journal.js')
|
|
|
|
const path = settingsPathFor({ ...scope, cwd: process.cwd() })
|
|
const built = buildUninstall(path)
|
|
for (const note of built.notes) console.log(chalk.dim(` ${note}`))
|
|
if (built.plan) {
|
|
const record = await runAction(built.plan)
|
|
console.log(` Uninstalled ${chalk.bold(shortId(record.id))} ${built.plan.description}`)
|
|
console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))
|
|
}
|
|
}
|
|
|
|
async function doStatus(): Promise<void> {
|
|
const { readGuardConfig } = await import('./store.js')
|
|
const { inspectInstall, settingsPathFor } = await import('./settings.js')
|
|
const { readFlags, flagsAgeMs } = await import('./flags.js')
|
|
|
|
const config = await readGuardConfig()
|
|
console.log(chalk.bold('\n codeburn guard'))
|
|
console.log(` soft cap: ${usd(config.softUSD)}`)
|
|
console.log(` hard cap: ${usd(config.hardUSD)}`)
|
|
console.log(` checkpoint: ${usd(config.checkpointUSD)}`)
|
|
console.log(` openers: ${config.openerEnabled ? 'on' : 'off'}`)
|
|
|
|
const locations = [
|
|
{ label: 'global', path: settingsPathFor({ global: true }) },
|
|
{ label: 'project', path: settingsPathFor({ cwd: process.cwd() }) },
|
|
]
|
|
const found = locations
|
|
.map(l => ({ ...l, info: inspectInstall(l.path) }))
|
|
.filter(l => l.info.hooks.length > 0 || l.info.statusline)
|
|
if (found.length === 0) {
|
|
console.log(' installed: nowhere (run: codeburn guard install)')
|
|
} else {
|
|
for (const l of found) {
|
|
const bits = [...new Set(l.info.hooks)].join(', ')
|
|
console.log(` installed: ${l.label} ${l.path} [${bits}${l.info.statusline ? ', statusline' : ''}]`)
|
|
}
|
|
}
|
|
|
|
const flags = await readFlags()
|
|
if (!flags) {
|
|
console.log(' flags: none (run: codeburn guard refresh)')
|
|
} else {
|
|
const ageDays = flagsAgeMs(flags) / 86_400_000
|
|
console.log(` flags: ${flags.projects.length} project${flags.projects.length === 1 ? '' : 's'}, ${ageDays.toFixed(1)}d old`)
|
|
}
|
|
console.log()
|
|
}
|
|
|
|
async function doAllow(sessionId: string | undefined): Promise<void> {
|
|
const { sessionsDir } = await import('./store.js')
|
|
const { writeAllow } = await import('./usage.js')
|
|
let id = sessionId
|
|
if (!id) {
|
|
const dir = sessionsDir()
|
|
const names = (await readdir(dir).catch(() => [])).filter(f => f.endsWith('.json'))
|
|
let newest = { at: -1, id: '' }
|
|
for (const name of names) {
|
|
const st = await stat(join(dir, name)).catch(() => null)
|
|
if (!st) continue
|
|
if (st.mtimeMs > newest.at) {
|
|
try {
|
|
const cache = JSON.parse(await readFile(join(dir, name), 'utf-8')) as { sessionId?: string }
|
|
if (cache.sessionId) newest = { at: st.mtimeMs, id: cache.sessionId }
|
|
} catch { /* skip unreadable cache */ }
|
|
}
|
|
}
|
|
id = newest.id
|
|
}
|
|
if (!id) {
|
|
console.error(' No active guard session found. Pass the session id: codeburn guard allow <session-id>.')
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
await writeAllow(id)
|
|
console.log(` Lifted the guard hard cap for session ${id} (this session only).`)
|
|
}
|
|
|
|
export function registerGuardCommands(program: Command): void {
|
|
const guard = program
|
|
.command('guard')
|
|
.description('Opt-in, removable session-time hooks for Claude Code (budget caps, openers, yield checkpoint)')
|
|
|
|
guard
|
|
.command('install')
|
|
.description('Install the guard hooks into Claude Code settings (default: this project)')
|
|
.option('--global', 'Install into ~/.claude/settings.json instead of the project')
|
|
.option('--project <path>', 'Install into <path>/.claude/settings.json')
|
|
.option('--statusline', 'Also configure the guard statusline (skipped if one already exists)')
|
|
.action(async (opts: { global?: boolean; project?: string; statusline?: boolean }) => {
|
|
try {
|
|
await doInstall({ global: opts.global, project: opts.project }, !!opts.statusline)
|
|
} catch (e) {
|
|
console.error(` ${e instanceof Error ? e.message : String(e)}`)
|
|
process.exitCode = 1
|
|
}
|
|
})
|
|
|
|
guard
|
|
.command('uninstall')
|
|
.description('Remove the guard hooks, leaving any user hooks untouched')
|
|
.option('--global', 'Uninstall from ~/.claude/settings.json')
|
|
.option('--project <path>', 'Uninstall from <path>/.claude/settings.json')
|
|
.action(async (opts: { global?: boolean; project?: string }) => {
|
|
try {
|
|
await doUninstall({ global: opts.global, project: opts.project })
|
|
} catch (e) {
|
|
console.error(` ${e instanceof Error ? e.message : String(e)}`)
|
|
process.exitCode = 1
|
|
}
|
|
})
|
|
|
|
guard
|
|
.command('status')
|
|
.description('Show resolved guard config, install locations, and the flag list')
|
|
.action(async () => { await doStatus() })
|
|
|
|
guard
|
|
.command('refresh')
|
|
.description('Recompute the per-project session-opener flag list from optimize signals')
|
|
.action(async () => {
|
|
try {
|
|
const n = await refreshFlags()
|
|
console.log(` Flagged ${n} project${n === 1 ? '' : 's'} for session openers.`)
|
|
} catch (e) {
|
|
console.error(` ${e instanceof Error ? e.message : String(e)}`)
|
|
process.exitCode = 1
|
|
}
|
|
})
|
|
|
|
guard
|
|
.command('allow [sessionId]')
|
|
.description('Lift the hard budget cap for the current (or given) session')
|
|
.action(async (sessionId: string | undefined) => { await doAllow(sessionId) })
|
|
|
|
guard
|
|
.command('hook <event>')
|
|
.description('Internal: Claude Code invokes this with the hook payload on stdin')
|
|
.action(async (event: string) => {
|
|
const { runGuardHook } = await import('./hooks.js')
|
|
const out = await runGuardHook(event, await readStdin())
|
|
if (out) process.stdout.write(out)
|
|
})
|
|
|
|
guard
|
|
.command('statusline')
|
|
.description('Internal: Claude Code statusline command; prints one line')
|
|
.action(async () => {
|
|
const { runGuardStatusline } = await import('./hooks.js')
|
|
const out = await runGuardStatusline(await readStdin())
|
|
if (out) process.stdout.write(out + '\n')
|
|
})
|
|
}
|