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:
AgentSeal 2026-07-03 11:54:04 +02:00
parent ab949f35a4
commit dc182dc275
9 changed files with 1294 additions and 0 deletions

221
src/guard/cli.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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')
}

View file

@ -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
View 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
View 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()
})
})