mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
feat(guard): opt-in session-time hook pack for Claude Code (#605)
codeburn guard install|uninstall|status|refresh|allow plus the internal hook/statusline handlers. Off by default, fully local, cleanly removable. - Settings edits go through the action journal (guard-install / guard-uninstall) with expectedHash, appending our entries and removing exactly ours by command prefix; a byte-identical uninstall is asserted by test. - PreToolUse budget cap (soft warn once, hard block with a per-session allow override), Stop yield checkpoint (expensive with no edits and no commit, once), and a flagged-project SessionStart opener built from the optimize detectors. - Incremental per-session cache keyed by session id: resumes the transcript parse from the last complete-line byte offset via readSessionLines, folding only the tail into running totals. Warm invocation ~0.28s against a 90MB transcript, dominated by CLI startup; the tail parse itself is negligible. - All handlers fail open: any error, malformed stdin, or missing transcript exits 0 with no output so a broken guard can never block a session. - Hook protocol verified against the live docs (dated block at the top of hooks.ts); zero new dependencies.
This commit is contained in:
parent
ab949f35a4
commit
dc182dc275
9 changed files with 1294 additions and 0 deletions
221
src/guard/cli.ts
Normal file
221
src/guard/cli.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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) {
|
||||
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 { guardDir } = await import('./store.js')
|
||||
const { writeAllow } = await import('./usage.js')
|
||||
let id = sessionId
|
||||
if (!id) {
|
||||
const dir = guardDir()
|
||||
const names = (await readdir(dir).catch(() => [])).filter(f => f.endsWith('.json') && f !== 'flags.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')
|
||||
})
|
||||
}
|
||||
73
src/guard/flags.ts
Normal file
73
src/guard/flags.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import { sep } from 'path'
|
||||
import type { ProjectSummary } from '../types.js'
|
||||
import { flagsPath, guardDir } from './store.js'
|
||||
|
||||
// Per-project flag list computed at install / `guard refresh` time and read on
|
||||
// SessionStart. The resolved opener text is stored here (not a code path into
|
||||
// optimize) so the hot SessionStart handler never imports the analyzer.
|
||||
export type ProjectFlag = { path: string; openers: string[] }
|
||||
export type GuardFlags = { generatedAt: string; projects: ProjectFlag[] }
|
||||
|
||||
export const FLAG_STALE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// optimize is loaded lazily so importing this module (for readFlags/matchFlag,
|
||||
// which the SessionStart hook needs) does not pull the analyzer.
|
||||
export async function buildFlags(projects: ProjectSummary[]): Promise<GuardFlags> {
|
||||
const { findLowWorthCandidates, findContextBloatCandidates, LOW_WORTH_OPENER, CONTEXT_HEAVY_OPENER } =
|
||||
await import('../optimize.js')
|
||||
const lowWorth = new Set(findLowWorthCandidates(projects).map(c => c.project))
|
||||
const contextHeavy = new Set(findContextBloatCandidates(projects).map(c => c.project))
|
||||
const flags: ProjectFlag[] = []
|
||||
for (const project of projects) {
|
||||
const openers: string[] = []
|
||||
if (lowWorth.has(project.project)) openers.push(LOW_WORTH_OPENER)
|
||||
if (contextHeavy.has(project.project)) openers.push(CONTEXT_HEAVY_OPENER)
|
||||
if (openers.length > 0) flags.push({ path: project.projectPath, openers })
|
||||
}
|
||||
return { generatedAt: new Date().toISOString(), projects: flags }
|
||||
}
|
||||
|
||||
export async function readFlags(base?: string): Promise<GuardFlags | null> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(flagsPath(base), 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as GuardFlags
|
||||
if (typeof parsed?.generatedAt !== 'string' || !Array.isArray(parsed.projects)) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFlags(flags: GuardFlags, base?: string): Promise<void> {
|
||||
await mkdir(guardDir(base), { recursive: true })
|
||||
await writeFile(flagsPath(base), JSON.stringify(flags, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
export function flagsAgeMs(flags: GuardFlags): number {
|
||||
const t = Date.parse(flags.generatedAt)
|
||||
return Number.isNaN(t) ? Number.POSITIVE_INFINITY : Date.now() - t
|
||||
}
|
||||
|
||||
function norm(p: string): string {
|
||||
return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p
|
||||
}
|
||||
|
||||
// Openers for the flagged project the cwd sits in (exact match or a subdir of
|
||||
// it); most-specific project wins. Empty when the cwd is not flagged.
|
||||
export function matchFlag(flags: GuardFlags, cwd: string): string[] {
|
||||
const target = norm(cwd)
|
||||
let best: ProjectFlag | null = null
|
||||
for (const flag of flags.projects) {
|
||||
const base = norm(flag.path)
|
||||
if (target === base || target.startsWith(base + sep)) {
|
||||
if (!best || base.length > norm(best.path).length) best = flag
|
||||
}
|
||||
}
|
||||
return best ? best.openers : []
|
||||
}
|
||||
166
src/guard/hooks.ts
Normal file
166
src/guard/hooks.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// ===========================================================================
|
||||
// Claude Code hook protocol, verified against the live docs on 2026-07-03
|
||||
// (https://docs.anthropic.com/en/docs/claude-code/hooks -> 301 ->
|
||||
// https://code.claude.com/docs/en/hooks, and .../statusline). The spec's field
|
||||
// names were NOT trusted; these are the doc's:
|
||||
//
|
||||
// Event names (exact casing): PreToolUse, SessionStart, Stop.
|
||||
// stdin JSON (snake_case) common to every event: session_id, transcript_path,
|
||||
// cwd, hook_event_name, permission_mode. PreToolUse adds tool_name +
|
||||
// tool_input; SessionStart adds source (startup|resume|clear|compact).
|
||||
// statusLine stdin differs: session_id, transcript_path, workspace.current_dir,
|
||||
// cost.total_cost_usd (plain text out, first stdout line rendered).
|
||||
// Exit/stdout contract: exit 0 -> stdout parsed as JSON output; exit 2 ->
|
||||
// blocking, stderr fed to the model. We ALWAYS exit 0 and encode any decision
|
||||
// as JSON, so an internal error is indistinguishable from "no opinion"
|
||||
// (fail-open). Empty stdout = no effect.
|
||||
// PreToolUse block: { hookSpecificOutput: { hookEventName: "PreToolUse",
|
||||
// permissionDecision: "deny", permissionDecisionReason } } (permissionDecision
|
||||
// is allow|deny|ask|defer). Non-blocking user note anywhere: top-level
|
||||
// systemMessage.
|
||||
// SessionStart context: { hookSpecificOutput: { hookEventName: "SessionStart",
|
||||
// additionalContext } }; SessionStart cannot block.
|
||||
// Stop: may block via top-level { decision: "block", reason } or exit 2; we
|
||||
// never do; a non-blocking nudge uses systemMessage (additionalContext on Stop
|
||||
// would force the conversation to continue, which we do not want).
|
||||
// settings.json shape: { hooks: { <Event>: [ { matcher?, hooks: [ { type:
|
||||
// "command", command } ] } ] } }. Stop takes no matcher; SessionStart matcher
|
||||
// is startup|resume|clear|compact; an omitted matcher matches all. Statusline
|
||||
// is a top-level statusLine: { type: "command", command }.
|
||||
// ===========================================================================
|
||||
import { readGuardConfig } from './store.js'
|
||||
import { computeSessionUsage, isAllowed, readCache, writeCache } from './usage.js'
|
||||
import { FLAG_STALE_MS, flagsAgeMs, matchFlag, readFlags } from './flags.js'
|
||||
|
||||
export type HookOpts = { base?: string }
|
||||
|
||||
function str(obj: unknown, key: string): string | undefined {
|
||||
if (obj && typeof obj === 'object') {
|
||||
const v = (obj as Record<string, unknown>)[key]
|
||||
if (typeof v === 'string') return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function usd(n: number): string {
|
||||
return `$${n.toFixed(2)}`
|
||||
}
|
||||
|
||||
async function handlePreToolUse(input: unknown, opts: HookOpts): Promise<string> {
|
||||
const sessionId = str(input, 'session_id')
|
||||
const transcript = str(input, 'transcript_path')
|
||||
if (!sessionId || !transcript) return ''
|
||||
|
||||
const config = await readGuardConfig(opts.base)
|
||||
const prev = await readCache(sessionId, opts.base)
|
||||
const { cache } = await computeSessionUsage(prev, transcript)
|
||||
|
||||
let output = ''
|
||||
if (config.hardUSD !== null && cache.costUSD >= config.hardUSD && !(await isAllowed(sessionId, opts.base))) {
|
||||
// A block is a stronger signal than the soft nag; once blocked, don't also
|
||||
// fire a soft warning on the next (e.g. post-`allow`) tool call.
|
||||
cache.softWarned = true
|
||||
output = JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason:
|
||||
`Session cost passed ${usd(config.hardUSD)} (codeburn guard). Run 'codeburn guard allow' to lift the cap for this session, or raise hardUSD in guard.json.`,
|
||||
},
|
||||
})
|
||||
} else if (config.softUSD !== null && cache.costUSD >= config.softUSD && !cache.softWarned) {
|
||||
cache.softWarned = true
|
||||
output = JSON.stringify({
|
||||
systemMessage: `codeburn guard: this session is ${usd(cache.costUSD)} (soft cap ${usd(config.softUSD)}).`,
|
||||
})
|
||||
}
|
||||
|
||||
await writeCache(cache, opts.base)
|
||||
return output
|
||||
}
|
||||
|
||||
async function handleSessionStart(input: unknown, opts: HookOpts): Promise<string> {
|
||||
const cwd = str(input, 'cwd')
|
||||
if (!cwd) return ''
|
||||
const config = await readGuardConfig(opts.base)
|
||||
if (!config.openerEnabled) return ''
|
||||
const flags = await readFlags(opts.base)
|
||||
if (!flags || flagsAgeMs(flags) > FLAG_STALE_MS) return ''
|
||||
const openers = matchFlag(flags, cwd)
|
||||
if (openers.length === 0) return ''
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: openers.join('\n\n') },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStop(input: unknown, opts: HookOpts): Promise<string> {
|
||||
const sessionId = str(input, 'session_id')
|
||||
const transcript = str(input, 'transcript_path')
|
||||
if (!sessionId || !transcript) return ''
|
||||
|
||||
const config = await readGuardConfig(opts.base)
|
||||
const prev = await readCache(sessionId, opts.base)
|
||||
const { cache } = await computeSessionUsage(prev, transcript)
|
||||
|
||||
let output = ''
|
||||
if (
|
||||
config.checkpointUSD !== null
|
||||
&& cache.costUSD > config.checkpointUSD
|
||||
&& cache.editCount === 0
|
||||
&& !cache.sawGitCommit
|
||||
&& !cache.stopNotified
|
||||
) {
|
||||
cache.stopNotified = true
|
||||
output = JSON.stringify({
|
||||
systemMessage:
|
||||
`This session is ${usd(cache.costUSD)} with no edits or commits yet. If exploring is the goal, fine; otherwise consider a fresh session with a named deliverable.`,
|
||||
})
|
||||
}
|
||||
|
||||
await writeCache(cache, opts.base)
|
||||
return output
|
||||
}
|
||||
|
||||
// The fail-open boundary: parse stdin, dispatch, and turn ANY error, malformed
|
||||
// payload, or unknown event into exit-0-with-empty-output. A broken guard must
|
||||
// never block a session.
|
||||
export async function runGuardHook(event: string, raw: string, opts: HookOpts = {}): Promise<string> {
|
||||
try {
|
||||
const input = JSON.parse(raw) as unknown
|
||||
switch (event.toLowerCase()) {
|
||||
case 'pretooluse': return await handlePreToolUse(input, opts)
|
||||
case 'sessionstart': return await handleSessionStart(input, opts)
|
||||
case 'stop': return await handleStop(input, opts)
|
||||
default: return ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// statusLine is a separate command, not a hook event. One line: guard's session
|
||||
// cost and how stale the incremental cache is versus a 5-minute turn TTL.
|
||||
export const STATUSLINE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
export async function runGuardStatusline(raw: string, opts: HookOpts = {}): Promise<string> {
|
||||
try {
|
||||
const input = JSON.parse(raw) as unknown
|
||||
const sessionId = str(input, 'session_id')
|
||||
const transcript = str(input, 'transcript_path')
|
||||
if (!sessionId || !transcript) return ''
|
||||
const prev = await readCache(sessionId, opts.base)
|
||||
const { cache } = await computeSessionUsage(prev, transcript)
|
||||
await writeCache(cache, opts.base)
|
||||
return `codeburn guard ${usd(cache.costUSD)} · ${freshness(cache.lastTurnAt)}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function freshness(lastTurnAt: string | null): string {
|
||||
if (!lastTurnAt) return 'no turns yet'
|
||||
const age = Date.now() - Date.parse(lastTurnAt)
|
||||
if (Number.isNaN(age) || age < 0) return 'fresh'
|
||||
const label = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m`
|
||||
return age > STATUSLINE_TTL_MS ? `idle ${label}` : label
|
||||
}
|
||||
199
src/guard/settings.ts
Normal file
199
src/guard/settings.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { existsSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { sha256 } from '../act/backup.js'
|
||||
import type { ActionPlan, PlannedChange } from '../act/types.js'
|
||||
|
||||
// The hook entries `guard install` writes and `guard uninstall` removes. Every
|
||||
// command carries the same recognizable prefix so uninstall can find exactly
|
||||
// ours by substring even if the user later moved or reindented the file.
|
||||
export const GUARD_HOOK_PREFIX = 'codeburn guard hook'
|
||||
export const GUARD_STATUSLINE_COMMAND = 'codeburn guard statusline'
|
||||
|
||||
const INSTALL_HOOKS: { event: string; matcher?: string; arg: string }[] = [
|
||||
{ event: 'PreToolUse', arg: 'pretooluse' },
|
||||
{ event: 'SessionStart', matcher: 'startup', arg: 'sessionstart' },
|
||||
{ event: 'Stop', arg: 'stop' },
|
||||
]
|
||||
|
||||
function hookCommand(arg: string): string {
|
||||
return `${GUARD_HOOK_PREFIX} ${arg}`
|
||||
}
|
||||
|
||||
export function settingsPathFor(scope: { global?: boolean; project?: string; cwd?: string }): string {
|
||||
const dir = scope.global ? homedir() : (scope.project ?? scope.cwd ?? process.cwd())
|
||||
return join(dir, '.claude', 'settings.json')
|
||||
}
|
||||
|
||||
type Loaded = { doc: Record<string, unknown>; existed: boolean; rawHash: string | null }
|
||||
|
||||
function load(path: string): Loaded {
|
||||
if (!existsSync(path)) return { doc: {}, existed: false, rawHash: null }
|
||||
const buf = readFileSync(path)
|
||||
const rawHash = sha256(buf)
|
||||
let raw = buf.toString('utf-8')
|
||||
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1)
|
||||
let doc: unknown
|
||||
try {
|
||||
doc = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
throw new Error(`could not parse ${path}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
throw new Error(`${path} is not a JSON object`)
|
||||
}
|
||||
return { doc: doc as Record<string, unknown>, existed: true, rawHash }
|
||||
}
|
||||
|
||||
type HookEntry = { type?: string; command?: string; [k: string]: unknown }
|
||||
type MatcherGroup = { matcher?: string; hooks?: HookEntry[]; [k: string]: unknown }
|
||||
|
||||
function asGroups(value: unknown): MatcherGroup[] {
|
||||
return Array.isArray(value) ? (value as MatcherGroup[]) : []
|
||||
}
|
||||
|
||||
function groupHasOurCommand(group: MatcherGroup, command: string): boolean {
|
||||
return Array.isArray(group.hooks) && group.hooks.some(h => h?.command === command)
|
||||
}
|
||||
|
||||
export type SettingsBuild = {
|
||||
plan: ActionPlan | null
|
||||
path: string
|
||||
existed: boolean
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
function change(path: string, existed: boolean, rawHash: string | null, doc: Record<string, unknown>): PlannedChange {
|
||||
return {
|
||||
op: existed ? 'edit' : 'create',
|
||||
path,
|
||||
content: JSON.stringify(doc, null, 2) + '\n',
|
||||
expectedHash: rawHash,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInstall(path: string, opts: { statusline?: boolean } = {}): SettingsBuild {
|
||||
const { doc, existed, rawHash } = load(path)
|
||||
const notes: string[] = []
|
||||
const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks))
|
||||
? doc.hooks as Record<string, unknown>
|
||||
: {}
|
||||
let added = false
|
||||
|
||||
for (const { event, matcher, arg } of INSTALL_HOOKS) {
|
||||
const command = hookCommand(arg)
|
||||
const groups = asGroups(hooks[event])
|
||||
if (groups.some(g => groupHasOurCommand(g, command))) continue
|
||||
groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] })
|
||||
hooks[event] = groups
|
||||
added = true
|
||||
}
|
||||
if (added || Object.keys(hooks).length > 0) doc.hooks = hooks
|
||||
|
||||
if (opts.statusline) {
|
||||
const existing = doc.statusLine
|
||||
if (existing && typeof existing === 'object' && (existing as HookEntry).command !== GUARD_STATUSLINE_COMMAND) {
|
||||
notes.push('a statusline is already configured; left it untouched (remove it first to use the guard statusline)')
|
||||
} else if ((existing as HookEntry | undefined)?.command === GUARD_STATUSLINE_COMMAND) {
|
||||
// already ours
|
||||
} else {
|
||||
doc.statusLine = { type: 'command', command: GUARD_STATUSLINE_COMMAND }
|
||||
added = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
notes.push('guard hooks already present; nothing to install')
|
||||
return { plan: null, path, existed, notes }
|
||||
}
|
||||
return {
|
||||
plan: {
|
||||
kind: 'guard-install',
|
||||
findingId: null,
|
||||
description: `Install codeburn guard hooks into ${path}`,
|
||||
changes: [change(path, existed, rawHash, doc)],
|
||||
},
|
||||
path,
|
||||
existed,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUninstall(path: string): SettingsBuild {
|
||||
if (!existsSync(path)) {
|
||||
return { plan: null, path, existed: false, notes: ['no settings file at that location; nothing to uninstall'] }
|
||||
}
|
||||
const { doc, existed, rawHash } = load(path)
|
||||
let removed = false
|
||||
|
||||
const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks))
|
||||
? doc.hooks as Record<string, unknown>
|
||||
: null
|
||||
if (hooks) {
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const groups = asGroups(hooks[event])
|
||||
const kept: MatcherGroup[] = []
|
||||
for (const group of groups) {
|
||||
if (!Array.isArray(group.hooks)) { kept.push(group); continue }
|
||||
const keptHooks = group.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX)))
|
||||
if (keptHooks.length !== group.hooks.length) removed = true
|
||||
if (keptHooks.length === 0) continue // drop a group that was only ours
|
||||
kept.push(keptHooks.length === group.hooks.length ? group : { ...group, hooks: keptHooks })
|
||||
}
|
||||
if (kept.length === 0) delete hooks[event]
|
||||
else hooks[event] = kept
|
||||
}
|
||||
if (Object.keys(hooks).length === 0) delete doc.hooks
|
||||
}
|
||||
|
||||
const statusLine = doc.statusLine as HookEntry | undefined
|
||||
if (statusLine && typeof statusLine.command === 'string' && statusLine.command.includes(GUARD_STATUSLINE_COMMAND)) {
|
||||
delete doc.statusLine
|
||||
removed = true
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
return { plan: null, path, existed, notes: ['no codeburn guard hooks found in that settings file'] }
|
||||
}
|
||||
return {
|
||||
plan: {
|
||||
kind: 'guard-uninstall',
|
||||
findingId: null,
|
||||
description: `Remove codeburn guard hooks from ${path}`,
|
||||
changes: [change(path, existed, rawHash, doc)],
|
||||
},
|
||||
path,
|
||||
existed,
|
||||
notes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Report whether a settings file currently carries our hooks / statusline, for
|
||||
// `guard status`. Never throws: a missing or malformed file reads as absent.
|
||||
export function inspectInstall(path: string): { path: string; hooks: string[]; statusline: boolean } {
|
||||
const out = { path, hooks: [] as string[], statusline: false }
|
||||
if (!existsSync(path)) return out
|
||||
let doc: Record<string, unknown>
|
||||
try {
|
||||
let raw = readFileSync(path, 'utf-8')
|
||||
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1)
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return out
|
||||
doc = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
const hooks = doc.hooks
|
||||
if (hooks && typeof hooks === 'object') {
|
||||
for (const [event, value] of Object.entries(hooks as Record<string, unknown>)) {
|
||||
for (const group of asGroups(value)) {
|
||||
if (Array.isArray(group.hooks) && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) {
|
||||
out.hooks.push(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const sl = doc.statusLine as HookEntry | undefined
|
||||
out.statusline = !!(sl && typeof sl.command === 'string' && sl.command.includes(GUARD_STATUSLINE_COMMAND))
|
||||
return out
|
||||
}
|
||||
86
src/guard/store.ts
Normal file
86
src/guard/store.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { getConfigFilePath } from '../config.js'
|
||||
|
||||
// Guard state lives beside config.json under the CodeBurn home dir (in practice
|
||||
// ~/.config/codeburn): guard.json for thresholds, a guard/ subdir for the
|
||||
// per-session incremental caches, the flag list, and per-session allow markers.
|
||||
// Every path derives from an injectable base so tests point the whole thing at
|
||||
// a fixture dir and the real config dir is never touched.
|
||||
export function guardBase(base?: string): string {
|
||||
return base ?? dirname(getConfigFilePath())
|
||||
}
|
||||
|
||||
export function guardConfigPath(base?: string): string {
|
||||
return join(guardBase(base), 'guard.json')
|
||||
}
|
||||
|
||||
export function guardDir(base?: string): string {
|
||||
return join(guardBase(base), 'guard')
|
||||
}
|
||||
|
||||
export function flagsPath(base?: string): string {
|
||||
return join(guardDir(base), 'flags.json')
|
||||
}
|
||||
|
||||
export function sessionCachePath(sessionId: string, base?: string): string {
|
||||
return join(guardDir(base), `${sanitizeId(sessionId)}.json`)
|
||||
}
|
||||
|
||||
export function allowPath(sessionId: string, base?: string): string {
|
||||
return join(guardDir(base), `${sanitizeId(sessionId)}.allow`)
|
||||
}
|
||||
|
||||
// Session ids come from the hook payload; keep them to a filesystem-safe set so
|
||||
// a malformed id can never escape the guard dir.
|
||||
function sanitizeId(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9._-]/g, '_')
|
||||
}
|
||||
|
||||
export type GuardConfig = {
|
||||
softUSD: number | null
|
||||
hardUSD: number | null
|
||||
checkpointUSD: number | null
|
||||
openerEnabled: boolean
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const DEFAULT_GUARD_CONFIG: GuardConfig = {
|
||||
softUSD: 5,
|
||||
hardUSD: 15,
|
||||
checkpointUSD: 3,
|
||||
openerEnabled: true,
|
||||
updatedAt: '',
|
||||
}
|
||||
|
||||
function coerceThreshold(v: unknown, fallback: number | null): number | null {
|
||||
if (v === null) return null
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback
|
||||
}
|
||||
|
||||
export async function readGuardConfig(base?: string): Promise<GuardConfig> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(guardConfigPath(base), 'utf-8')
|
||||
} catch {
|
||||
return { ...DEFAULT_GUARD_CONFIG }
|
||||
}
|
||||
let parsed: Partial<GuardConfig>
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Partial<GuardConfig>
|
||||
} catch {
|
||||
return { ...DEFAULT_GUARD_CONFIG }
|
||||
}
|
||||
return {
|
||||
softUSD: coerceThreshold(parsed.softUSD, DEFAULT_GUARD_CONFIG.softUSD),
|
||||
hardUSD: coerceThreshold(parsed.hardUSD, DEFAULT_GUARD_CONFIG.hardUSD),
|
||||
checkpointUSD: coerceThreshold(parsed.checkpointUSD, DEFAULT_GUARD_CONFIG.checkpointUSD),
|
||||
openerEnabled: parsed.openerEnabled !== false,
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeGuardConfig(config: GuardConfig, base?: string): Promise<void> {
|
||||
await mkdir(guardBase(base), { recursive: true })
|
||||
await writeFile(guardConfigPath(base), JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
127
src/guard/usage.ts
Normal file
127
src/guard/usage.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { mkdir, readFile, stat, writeFile } from 'fs/promises'
|
||||
import { readSessionLines } from '../fs-utils.js'
|
||||
import { parseApiCall, parseJsonlLine } from '../parser.js'
|
||||
import { EDIT_TOOLS } from '../classifier.js'
|
||||
import { allowPath, guardDir, sessionCachePath } from './store.js'
|
||||
|
||||
// Per-session running totals. The transcript is append-only, so each invocation
|
||||
// streams only the bytes after `byteOffset` (the offset of the last complete
|
||||
// line parsed) and folds them into the totals; a cold parse of a multi-hundred-
|
||||
// MB transcript on every tool call is what this avoids.
|
||||
export type GuardCache = {
|
||||
version: number
|
||||
sessionId: string
|
||||
byteOffset: number
|
||||
costUSD: number
|
||||
editCount: number
|
||||
sawGitCommit: boolean
|
||||
lastTurnAt: string | null
|
||||
updatedAt: string
|
||||
softWarned: boolean
|
||||
stopNotified: boolean
|
||||
}
|
||||
|
||||
export const GUARD_CACHE_VERSION = 1
|
||||
|
||||
// Raw command text is needed (not the base-binary names parseApiCall's
|
||||
// bashCommands reduces to), so the Stop check reads call.toolSequence.
|
||||
const GIT_COMMIT = /\bgit\b[\s\S]*?\bcommit\b/
|
||||
|
||||
export function emptyCache(sessionId: string): GuardCache {
|
||||
return {
|
||||
version: GUARD_CACHE_VERSION,
|
||||
sessionId,
|
||||
byteOffset: 0,
|
||||
costUSD: 0,
|
||||
editCount: 0,
|
||||
sawGitCommit: false,
|
||||
lastTurnAt: null,
|
||||
updatedAt: '',
|
||||
softWarned: false,
|
||||
stopNotified: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCache(sessionId: string, base?: string): Promise<GuardCache> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(sessionCachePath(sessionId, base), 'utf-8')
|
||||
} catch {
|
||||
return emptyCache(sessionId)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<GuardCache>
|
||||
if (parsed.version !== GUARD_CACHE_VERSION || typeof parsed.byteOffset !== 'number') {
|
||||
return emptyCache(sessionId)
|
||||
}
|
||||
return { ...emptyCache(sessionId), ...parsed, sessionId }
|
||||
} catch {
|
||||
return emptyCache(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCache(cache: GuardCache, base?: string): Promise<void> {
|
||||
await mkdir(guardDir(base), { recursive: true })
|
||||
await writeFile(sessionCachePath(cache.sessionId, base), JSON.stringify(cache), 'utf-8')
|
||||
}
|
||||
|
||||
// Fold the transcript tail into the totals. Reuses the streaming line reader
|
||||
// (startByteOffset + a lastCompleteLineOffset tracker) and the shared per-call
|
||||
// cost/pricing path (parseApiCall -> calculateCost), so the guard never
|
||||
// reimplements cost math. `resumedFrom` is the offset the parse restarted at,
|
||||
// which the test asserts to prove only the tail was read.
|
||||
export async function computeSessionUsage(
|
||||
prev: GuardCache,
|
||||
transcriptPath: string,
|
||||
): Promise<{ cache: GuardCache; resumedFrom: number }> {
|
||||
let size: number
|
||||
try {
|
||||
size = (await stat(transcriptPath)).size
|
||||
} catch {
|
||||
return { cache: prev, resumedFrom: prev.byteOffset }
|
||||
}
|
||||
|
||||
// A shorter file than we last read means the transcript was rotated or
|
||||
// truncated; start over from a clean total rather than trusting a stale
|
||||
// offset into different bytes.
|
||||
const cache = size < prev.byteOffset
|
||||
? { ...emptyCache(prev.sessionId), softWarned: prev.softWarned, stopNotified: prev.stopNotified }
|
||||
: { ...prev }
|
||||
const resumedFrom = cache.byteOffset
|
||||
|
||||
const tracker = { lastCompleteLineOffset: resumedFrom }
|
||||
for await (const line of readSessionLines(transcriptPath, undefined, {
|
||||
startByteOffset: resumedFrom,
|
||||
byteOffsetTracker: tracker,
|
||||
largeLineAsBuffer: true,
|
||||
})) {
|
||||
const entry = parseJsonlLine(line)
|
||||
if (!entry) continue
|
||||
const call = parseApiCall(entry)
|
||||
if (!call) continue
|
||||
cache.costUSD += call.costUSD
|
||||
for (const tc of call.toolSequence?.flat() ?? []) {
|
||||
if (EDIT_TOOLS.has(tc.tool)) cache.editCount++
|
||||
if (!cache.sawGitCommit && tc.command && GIT_COMMIT.test(tc.command)) cache.sawGitCommit = true
|
||||
}
|
||||
if (call.timestamp) cache.lastTurnAt = call.timestamp
|
||||
}
|
||||
|
||||
cache.byteOffset = tracker.lastCompleteLineOffset
|
||||
cache.updatedAt = new Date().toISOString()
|
||||
return { cache, resumedFrom }
|
||||
}
|
||||
|
||||
export async function isAllowed(sessionId: string, base?: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(allowPath(sessionId, base))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeAllow(sessionId: string, base?: string): Promise<void> {
|
||||
await mkdir(guardDir(base), { recursive: true })
|
||||
await writeFile(allowPath(sessionId, base), '', 'utf-8')
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import type { UsageQuery } from './sharing/share-server.js'
|
|||
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize } from './optimize.js'
|
||||
import { registerActCommands } from './act/cli.js'
|
||||
import { registerGuardCommands } from './guard/cli.js'
|
||||
import { runContextCommand } from './context-tree.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import {
|
||||
|
|
@ -1555,5 +1556,6 @@ program
|
|||
})
|
||||
|
||||
registerActCommands(program)
|
||||
registerGuardCommands(program)
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
276
tests/guard-hooks.test.ts
Normal file
276
tests/guard-hooks.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { appendFile, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js'
|
||||
import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js'
|
||||
import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js'
|
||||
import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
const roots: string[] = []
|
||||
async function tmp(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-'))
|
||||
roots.push(d)
|
||||
return d
|
||||
}
|
||||
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
|
||||
|
||||
type Tool = { name: string; input?: Record<string, unknown> }
|
||||
function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string {
|
||||
const content: Record<string, unknown>[] = [{ type: 'text', text: 'ok' }]
|
||||
for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} })
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: o.ts ?? '2026-07-01T00:00:00.000Z',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 },
|
||||
content,
|
||||
},
|
||||
}) + '\n'
|
||||
}
|
||||
|
||||
async function transcript(lines: string[]): Promise<string> {
|
||||
const dir = await tmp()
|
||||
const path = join(dir, 'session.jsonl')
|
||||
await writeFile(path, lines.join(''), 'utf-8')
|
||||
return path
|
||||
}
|
||||
|
||||
const SID = 'sess-1'
|
||||
function hookInput(path: string): string {
|
||||
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' })
|
||||
}
|
||||
|
||||
describe('incremental session cache', () => {
|
||||
it('parses only the appended tail and totals match a cold parse', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
|
||||
const r1 = await computeSessionUsage(emptyCache(SID), path)
|
||||
await writeCache(r1.cache, base)
|
||||
expect(r1.resumedFrom).toBe(0)
|
||||
expect(r1.cache.costUSD).toBeGreaterThan(0)
|
||||
const offset1 = r1.cache.byteOffset
|
||||
|
||||
await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8')
|
||||
const size = (await stat(path)).size
|
||||
|
||||
const prev = await readCache(SID, base)
|
||||
const r2 = await computeSessionUsage(prev, path)
|
||||
|
||||
// Bytes-read assertion: the second pass resumed exactly where the first
|
||||
// stopped and consumed only the appended region (not the whole file).
|
||||
expect(r2.resumedFrom).toBe(offset1)
|
||||
expect(r2.cache.byteOffset).toBe(size)
|
||||
expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0)
|
||||
expect(r2.resumedFrom).toBeLessThan(size)
|
||||
|
||||
const cold = await computeSessionUsage(emptyCache(SID), path)
|
||||
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
|
||||
expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD)
|
||||
})
|
||||
|
||||
it('resets to a cold parse when the transcript shrinks (rotation)', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
const r1 = await computeSessionUsage(emptyCache(SID), path)
|
||||
await writeCache(r1.cache, base)
|
||||
|
||||
await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content
|
||||
const r2 = await computeSessionUsage(await readCache(SID, base), path)
|
||||
expect(r2.resumedFrom).toBe(0)
|
||||
const cold = await computeSessionUsage(emptyCache(SID), path)
|
||||
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('budget hook (PreToolUse)', () => {
|
||||
async function costOf(path: string): Promise<number> {
|
||||
return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
|
||||
}
|
||||
|
||||
it('stays silent below the soft cap', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a')])
|
||||
const c = await costOf(path)
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base)
|
||||
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
|
||||
})
|
||||
|
||||
it('warns once on the soft cap, then suppresses the repeat', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
const c = await costOf(path)
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base)
|
||||
|
||||
const first = await runGuardHook('pretooluse', hookInput(path), { base })
|
||||
expect(JSON.parse(first).systemMessage).toContain('soft cap')
|
||||
const second = await runGuardHook('pretooluse', hookInput(path), { base })
|
||||
expect(second).toBe('')
|
||||
})
|
||||
|
||||
it('blocks on the hard cap with a deny decision, and allow lifts it', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
const c = await costOf(path)
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base)
|
||||
|
||||
const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base }))
|
||||
expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse')
|
||||
expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny')
|
||||
expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow')
|
||||
|
||||
await writeAllow(SID, base)
|
||||
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
|
||||
})
|
||||
|
||||
it('disables the cap when the threshold is null', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base)
|
||||
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('yield checkpoint (Stop)', () => {
|
||||
function stopInput(path: string): string {
|
||||
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' })
|
||||
}
|
||||
async function withCheckpoint(base: string, path: string): Promise<void> {
|
||||
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base)
|
||||
}
|
||||
|
||||
it('fires once for an expensive no-edit no-commit session', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a'), assistantLine('b')])
|
||||
await withCheckpoint(base, path)
|
||||
const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base }))
|
||||
expect(first.systemMessage).toContain('no edits or commits')
|
||||
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session
|
||||
})
|
||||
|
||||
it('stays silent when cost is below the checkpoint', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a')])
|
||||
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
|
||||
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base)
|
||||
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
|
||||
})
|
||||
|
||||
it('stays silent when the session made an edit', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')])
|
||||
await withCheckpoint(base, path)
|
||||
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
|
||||
})
|
||||
|
||||
it('stays silent when the session ran git commit', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')])
|
||||
await withCheckpoint(base, path)
|
||||
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session opener (SessionStart)', () => {
|
||||
it('emits the matching opener text for a flagged project and nothing when stale', async () => {
|
||||
const base = await tmp()
|
||||
const projectPath = '/tmp/flagged-project'
|
||||
const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] }
|
||||
await writeFlags(flags, base)
|
||||
|
||||
const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' })
|
||||
const out = JSON.parse(await runGuardHook('sessionstart', input, { base }))
|
||||
expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart')
|
||||
expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER')
|
||||
|
||||
// Unflagged cwd -> nothing.
|
||||
const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' })
|
||||
expect(await runGuardHook('sessionstart', other, { base })).toBe('')
|
||||
|
||||
// Stale flag list (> 7 days) -> nothing.
|
||||
const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects }
|
||||
await writeFlags(stale, base)
|
||||
expect(await runGuardHook('sessionstart', input, { base })).toBe('')
|
||||
})
|
||||
|
||||
it('builds flags from optimize candidate detectors and matches by project path', async () => {
|
||||
// A project whose only session has real spend but zero edit turns is a
|
||||
// low-worth candidate; buildFlags should flag its projectPath.
|
||||
const projects = [lowWorthProject()]
|
||||
const flags = await buildFlags(projects as unknown as ProjectSummary[])
|
||||
expect(flags.projects.length).toBeGreaterThan(0)
|
||||
expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0)
|
||||
expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches
|
||||
expect(matchFlag(flags, '/repo/beta')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail-open: malformed stdin', () => {
|
||||
it('every handler exits with empty output on garbage input', async () => {
|
||||
const base = await tmp()
|
||||
for (const ev of ['pretooluse', 'sessionstart', 'stop']) {
|
||||
expect(await runGuardHook(ev, 'not json {', { base })).toBe('')
|
||||
expect(await runGuardHook(ev, '', { base })).toBe('')
|
||||
}
|
||||
expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('')
|
||||
expect(await runGuardStatusline('not json {', { base })).toBe('')
|
||||
})
|
||||
|
||||
it('handlers stay silent when the transcript path is missing', async () => {
|
||||
const base = await tmp()
|
||||
const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' })
|
||||
expect(await runGuardHook('pretooluse', noPath, { base })).toBe('')
|
||||
expect(await runGuardHook('stop', noPath, { base })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusline', () => {
|
||||
it('prints one line with the session cost', async () => {
|
||||
const base = await tmp()
|
||||
const path = await transcript([assistantLine('a')])
|
||||
const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base })
|
||||
expect(out.startsWith('codeburn guard $')).toBe(true)
|
||||
expect(out).not.toContain('\n')
|
||||
})
|
||||
})
|
||||
|
||||
// A minimal ProjectSummary shaped just enough for findLowWorthCandidates:
|
||||
// meaningful cost, no delivery command, and no edit turns.
|
||||
function lowWorthProject(): Record<string, unknown> {
|
||||
const turn = {
|
||||
userMessage: 'do a thing',
|
||||
assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }],
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
sessionId: 's-alpha',
|
||||
category: 'exploration',
|
||||
retries: 0,
|
||||
hasEdits: false,
|
||||
}
|
||||
const session = {
|
||||
sessionId: 's-alpha',
|
||||
project: 'alpha',
|
||||
firstTimestamp: '2026-07-01T00:00:00Z',
|
||||
lastTimestamp: '2026-07-01T01:00:00Z',
|
||||
totalCostUSD: 40,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [turn],
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
|
||||
}
|
||||
return {
|
||||
project: 'alpha',
|
||||
projectPath: '/repo/alpha',
|
||||
sessions: [session],
|
||||
totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0,
|
||||
}
|
||||
}
|
||||
144
tests/guard-install.test.ts
Normal file
144
tests/guard-install.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runAction } from '../src/act/apply.js'
|
||||
import { readRecords } from '../src/act/journal.js'
|
||||
import {
|
||||
buildInstall, buildUninstall, inspectInstall, settingsPathFor,
|
||||
GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND,
|
||||
} from '../src/guard/settings.js'
|
||||
|
||||
const roots: string[] = []
|
||||
async function makeRoot(): Promise<{ settings: string; actionsDir: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-'))
|
||||
roots.push(root)
|
||||
await mkdir(join(root, '.claude'), { recursive: true })
|
||||
return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') }
|
||||
}
|
||||
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
|
||||
|
||||
function canonical(obj: unknown): string {
|
||||
return JSON.stringify(obj, null, 2) + '\n'
|
||||
}
|
||||
async function readJson(path: string): Promise<any> {
|
||||
return JSON.parse(await readFile(path, 'utf-8'))
|
||||
}
|
||||
|
||||
describe('settingsPathFor', () => {
|
||||
it('resolves project (default) and global scopes', () => {
|
||||
expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json'))
|
||||
expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json'))
|
||||
expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('guard install', () => {
|
||||
it('creates settings with all three hook events when none exists', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
await rm(settings, { force: true })
|
||||
const built = buildInstall(settings)
|
||||
expect(built.plan).not.toBeNull()
|
||||
expect(built.plan!.kind).toBe('guard-install')
|
||||
await runAction(built.plan!, actionsDir)
|
||||
|
||||
const doc = await readJson(settings)
|
||||
expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
|
||||
expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`)
|
||||
expect(doc.hooks.SessionStart[0].matcher).toBe('startup')
|
||||
expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher
|
||||
// journaled + undoable
|
||||
const records = await readRecords(actionsDir)
|
||||
expect(records[0]!.kind).toBe('guard-install')
|
||||
})
|
||||
|
||||
it('appends to pre-existing user hooks without disturbing them', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
const original = canonical({
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
||||
})
|
||||
await writeFile(settings, original)
|
||||
|
||||
await runAction(buildInstall(settings).plan!, actionsDir)
|
||||
const doc = await readJson(settings)
|
||||
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
|
||||
expect(commands).toContain('my-own-hook.sh')
|
||||
expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`)
|
||||
})
|
||||
|
||||
it('is idempotent: re-install adds nothing', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
await rm(settings, { force: true })
|
||||
await runAction(buildInstall(settings).plan!, actionsDir)
|
||||
const again = buildInstall(settings)
|
||||
expect(again.plan).toBeNull()
|
||||
expect(again.notes.join(' ')).toContain('already present')
|
||||
})
|
||||
|
||||
it('configures the statusline only when none exists', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
await rm(settings, { force: true })
|
||||
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
|
||||
expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND)
|
||||
|
||||
// A pre-existing statusline is refused, not overwritten.
|
||||
const { settings: s2, actionsDir: a2 } = await makeRoot()
|
||||
await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } }))
|
||||
const built = buildInstall(s2, { statusline: true })
|
||||
expect(built.notes.join(' ')).toContain('statusline is already configured')
|
||||
await runAction(built.plan!, a2)
|
||||
expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('guard uninstall', () => {
|
||||
it('removes exactly our entries and restores byte-identical settings', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
const original = canonical({
|
||||
permissions: { allow: [] },
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
||||
})
|
||||
await writeFile(settings, original)
|
||||
|
||||
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
|
||||
// ours are present now
|
||||
const mid = inspectInstall(settings)
|
||||
expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
|
||||
expect(mid.statusline).toBe(true)
|
||||
|
||||
await runAction(buildUninstall(settings).plan!, actionsDir)
|
||||
expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical
|
||||
const after = inspectInstall(settings)
|
||||
expect(after.hooks).toEqual([])
|
||||
expect(after.statusline).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a user hook that shares the PreToolUse array', async () => {
|
||||
const { settings, actionsDir } = await makeRoot()
|
||||
await writeFile(settings, canonical({
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
||||
}))
|
||||
await runAction(buildInstall(settings).plan!, actionsDir)
|
||||
await runAction(buildUninstall(settings).plan!, actionsDir)
|
||||
const doc = await readJson(settings)
|
||||
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
|
||||
expect(commands).toEqual(['my-own-hook.sh'])
|
||||
})
|
||||
|
||||
it('reports nothing to do on a settings file without our hooks', async () => {
|
||||
const { settings } = await makeRoot()
|
||||
await writeFile(settings, canonical({ hooks: {} }))
|
||||
const built = buildUninstall(settings)
|
||||
expect(built.plan).toBeNull()
|
||||
expect(built.notes.join(' ')).toContain('no codeburn guard hooks')
|
||||
})
|
||||
|
||||
it('reports nothing to do when the settings file is absent', async () => {
|
||||
const { settings } = await makeRoot()
|
||||
await rm(settings, { force: true })
|
||||
expect(existsSync(settings)).toBe(false)
|
||||
const built = buildUninstall(settings)
|
||||
expect(built.plan).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue