mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-18 13:34:24 +00:00
Claude Code rewrites each assistant message several times as it streams, every copy carrying the full final usage; the shipped parser dedupes these last-wins (dedupeStreamingMessageIds) but the guard fold summed every line, measuring real sessions at 2.5-2.8x their true cost and false-blocking the hard cap at roughly 40% of the configured spend. - The session cache now maps message id -> that id's cost contribution and each id-carrying line replaces its previous contribution; id-less lines keep plain adds. Validated against two real transcripts (90MB and 116MB): guard totals now equal the shipped deduped totals exactly. - Replace semantics also self-heal the trailing-line case: a complete final line without its newline is folded but byteOffset stops before it, so the next invocation re-reads it as a replace, not a double add. - editCount becomes a set-once sawEdit boolean so duplicate copies of an edit tool_use cannot inflate it; cache schema bumped to v2 (old caches cold-reparse once). - Per-session state moves to guard/sessions/ so a session id can never collide with the shared flags.json, dropping the doAllow special case. - The git-commit detector now requires commit as the git subcommand at a command boundary (start of string or line, or ; & |), with intra-command gaps that never cross newlines: 'git log --grep commit' and 'git diff && echo commit' no longer match, while newline-separated 'git add ...\ngit commit' in multi-line Bash calls now does (verified as a real false negative on a live transcript). - Corrected the statusline protocol note: each stdout line renders as its own row; we emit exactly one. - New tests: streaming-duplicate fixtures (3x identical, growing last-wins, incremental replace) asserted equal to a cold shipped-parser computation, the trailing-partial-line scenario, the commit-detector matrix, and a stale-plan test proving guard-install plans carry expectedHash (a concurrent settings edit aborts the apply and survives). The act list CLI spawn test now anchors to the repo root from the test file location.
158 lines
6.9 KiB
TypeScript
158 lines
6.9 KiB
TypeScript
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')
|
|
})
|
|
|
|
it('refuses to apply a plan when the settings file changed after it was built', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await writeFile(settings, canonical({ permissions: { allow: [] } }))
|
|
const built = buildInstall(settings)
|
|
expect(built.plan).not.toBeNull()
|
|
|
|
const concurrent = canonical({ permissions: { allow: ['Bash(ls:*)'] } })
|
|
await writeFile(settings, concurrent)
|
|
|
|
await expect(runAction(built.plan!, actionsDir)).rejects.toThrow(/changed since the plan was built/)
|
|
expect(await readFile(settings, 'utf-8')).toBe(concurrent) // the concurrent edit survives
|
|
expect(await readRecords(actionsDir)).toEqual([]) // nothing journaled
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|