Shows what actually ran next to what Claude said ran: checks, stale passes, and unbacked claims.

A mod for Claude Code that shows what actually ran next to what Claude said ran.
When Claude writes "all tests pass", Attest checks that against what it watched happen in the session: which test commands ran, whether they failed, and whether any file changed after the last pass. It then says, under the answer, whether the claim is backed.
It asks no model anything. Every verdict comes from rules you can read in hooks/ledger.ts.
/attest: a pane with three tabs. Gates, Claims and Runs.| Verdict | Meaning | | :- | :- | | backed | A check passed and nothing has changed since. | | stale | A check passed, then a file changed. The pass no longer counts. | | unbacked | No check ran, the last one failed, or its exit code was hidden. |
/plugin install attest-ledger --marketplace ramankrishna/attest-ledger
Answer y to add the marketplace, then pick a scope. Needs Claude Code v2.1.287 or later.
Attest already recognises common test, lint and type-check commands: pytest, npm test, cargo test, go test, make test, tsc, ruff, eslint and others. Run Claude as usual and the lines appear.
Run /attest init, or write .attest.json at the project root yourself:
{
"gates": [
{ "name": "unit", "command": "pytest -q" },
{ "name": "cartpole_floor", "command": "python eval.py --env CartPole-v1", "watch": ["ppo/"], "timeoutSec": 300 }
],
"frozen": ["gates.json"]
}
name and command are required. A Bash command that contains command counts as a run of that gate.watch lists the paths whose edits make a pass stale. Leave it out and any edit does.timeoutSec applies when you rerun the gate from the pane. Default 120, at most 600.frozen lists files Claude may not change without asking you. .attest.json is always frozen, so Claude cannot quietly lower a bar it is being measured against.pytest || true, set +e, and pytest | tail without pipefail all report success whatever the tests did. Such a run is shown and not counted..attest.json at the project root, and checks whether package.json, Cargo.toml, go.mod, pyproject.toml, pytest.ini or Makefile exist when you run /attest init..attest.json once, only when you run /attest init and the file does not exist.command through sh -c in the project root, only when you press a run button in the pane. The commands are the ones in your own .attest.json.Like every mod, it runs with the same access to your machine as Claude Code itself.
The full statement is in PRIVACY.md.
sed -i, mv and rm. A determined script can still get past it.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 569 lines1// Attest: what ran, next to what Claude said ran.
2//
3// The host watches every check Claude runs (a declared gate or a test-like
4// command) and every file it changes, then reads each answer for success
5// claims and says which the ledger backs. Nothing here asks a model anything.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Tab } from '../types'
11import {
12 addClaims,
13 applyConfig,
14 bashFrozenHit,
15 checkOf,
16 claimsOf,
17 clip,
18 CONFIG,
19 EMPTY,
20 exitOf,
21 gateLabel,
22 gateNote,
23 isFrozen,
24 isJunk,
25 maskOf,
26 parseConfig,
27 recordEdit,
28 recordRun,
29 relative,
30 runLine,
31 runTone,
32 seconds,
33 setRunning,
34 summarize,
35 turnLine,
36 verdictTone,
37} from './ledger'
38
39const PANE = 'attest'
40const REFUSE = 'Refuse'
41const ALLOW = 'Allow once'
42
43const ledger = atom({ plugin: 'attest-ledger', key: 'ledger' } as const, EMPTY)
44const tab = atom({ plugin: 'attest-ledger', key: 'tab' } as const, 'gates')
45
46// What the guards read without a call on `$`: a `.catch` handler may not make
47// one, and a guard that cannot tell must still refuse a frozen file.
48let root = ''
49let frozen: readonly string[] = []
50const toasted = new Set<string>()
51
52const redKey = (gate: string): string => `red:${root}:${gate}`
53
54const denyText = (path: string): string =>
55 `attest: ${path} is frozen in this repository and the user did not allow the change. ` +
56 'Leave it as it is, report the result you got, and ask before trying another way.'
57
58/** Reads `.attest.json` at the project root and lays it over the ledger. */
59async function load($: EngineInterface): Promise<void> {
60 root = await $.session.root()
61
62 const path = `${root}/${CONFIG}`
63
64 if (!(await $.fs.exists(path))) {
65 frozen = []
66 await update($, ledger, was => applyConfig(was, null, {}))
67
68 return
69 }
70
71 let text = ''
72
73 try {
74 text = await $.fs.read(path)
75 } catch {
76 text = ''
77 }
78
79 const config = parseConfig(text)
80
81 if (typeof config === 'string') {
82 // A config that does not parse stays frozen: the guard outlives the typo.
83 frozen = [CONFIG]
84 await update($, ledger, was => ({
85 ...was,
86 hasConfig: true,
87 configError: config,
88 frozen: [CONFIG],
89 }))
90
91 return
92 }
93
94 const seenRed: Record<string, boolean> = {}
95
96 for (const gate of config.gates) {
97 seenRed[gate.name] = (await $.store.get(redKey(gate.name))) !== undefined
98 }
99
100 frozen = [CONFIG, ...config.frozen]
101 await update($, ledger, was => applyConfig(was, config, seenRed))
102}
103
104/** Writes a starter `.attest.json` fitted to what the project root holds. */
105async function init($: EngineInterface): Promise<string> {
106 root = await $.session.root()
107
108 const path = `${root}/${CONFIG}`
109
110 if (await $.fs.exists(path)) return `${CONFIG} already exists. Edit it, then run /attest.`
111
112 const guesses: ReadonlyArray<readonly [string, string, string]> = [
113 ['package.json', 'unit', 'npm test'],
114 ['Cargo.toml', 'unit', 'cargo test'],
115 ['go.mod', 'unit', 'go test ./...'],
116 ['pyproject.toml', 'unit', 'pytest -q'],
117 ['pytest.ini', 'unit', 'pytest -q'],
118 ['Makefile', 'check', 'make test'],
119 ]
120 let gate = { name: 'unit', command: 'pytest -q' }
121
122 for (const [file, name, command] of guesses) {
123 if (await $.fs.exists(`${root}/${file}`)) {
124 gate = { name, command }
125 break
126 }
127 }
128
129 await $.fs.write(path, `${JSON.stringify({ gates: [gate], frozen: [] }, null, 2)}\n`)
130 await load($)
131
132 return `Wrote ${CONFIG} with one gate, ${gate.name}: ${gate.command}. Edit it to add yours; the file is frozen to Claude from now on.`
133}
134
135/** Asks the person before a frozen file changes; anything but Allow refuses. */
136async function isAllowed($: EngineInterface, what: string): Promise<boolean> {
137 try {
138 const answer = await $.ui.ask(`Claude wants to ${what}, which is frozen here. Allow it?`, {
139 header: 'attest',
140 options: [REFUSE, ALLOW],
141 })
142
143 return answer === ALLOW
144 } catch {
145 // Dismissed, or nobody to ask (`claude -p`): the safe answer stands.
146 return false
147 }
148}
149
150/** After a run landed: remember a gate seen failing, nudge about one never seen so. */
151async function settle($: EngineInterface, id: string): Promise<void> {
152 const now = await read($, ledger)
153 const run = now.runs.find(one => one.id === id)
154
155 if (run === undefined || run.gate === null || run.masked !== null) return
156
157 if (!run.ok) {
158 await $.store.set(redKey(run.gate), true)
159
160 return
161 }
162
163 const gate = now.gates.find(one => one.name === run.gate)
164
165 if (gate !== undefined && !gate.seenRed && !toasted.has(gate.name)) {
166 toasted.add(gate.name)
167 $.ui.toast(`${gate.name} has never failed. Break it once before you trust it.`)
168 }
169}
170
171/** Runs gates itself, on the person's press: the exit code is the host's own. */
172async function runGates($: EngineInterface, which: 'all' | 'stale'): Promise<void> {
173 const before = await read($, ledger)
174 const todo = before.gates.filter(gate => which === 'all' || gate.status !== 'pass')
175
176 for (const gate of todo) {
177 await update($, ledger, was => setRunning(was, gate.name, true))
178
179 const started = await $.clock.now()
180 let exit: number | null = null
181 let ok = false
182
183 try {
184 const ran = await $.process.run(['sh', '-c', gate.command], {
185 cwd: root,
186 timeoutMs: gate.timeoutSec * 1000,
187 })
188
189 exit = ran.exitCode
190 ok = ran.exitCode === 0
191 } catch {
192 // Timed out or could not start: a run that did not pass.
193 ok = false
194 }
195
196 const ms = (await $.clock.now()) - started
197 const id = `direct-${gate.name}-${started}`
198
199 await update($, ledger, was =>
200 recordRun(was, {
201 id,
202 command: gate.command,
203 ok,
204 exit,
205 ms,
206 masked: maskOf(gate.command),
207 gate: gate.name,
208 }),
209 )
210 await settle($, id)
211 }
212}
213
214const openPane = ($: EngineInterface) =>
215 $.ui.open({ id: PANE, title: 'Attest', focus: true, closeOnEscape: true })
216
217export const register: Register = on => {
218 on('session.start', async ($, e, next) => {
219 await $.command.register({
220 name: 'attest',
221 description: 'Show what ran next to what Claude said ran',
222 argumentHint: '[init]',
223 })
224 await load($)
225
226 return next(e)
227 })
228
229 // /clear, /resume and /branch reset the session's state and raise no
230 // session.start: read the config again so the gates and guards come back.
231 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
232 await load($)
233
234 return next(e)
235 })
236
237 on('command.run', { command: 'attest' }, async ($, e) => {
238 if (e.args.trim() === 'init') return { text: await init($) }
239
240 await load($)
241 await openPane($)
242
243 return {}
244 })
245
246 // ------------------------------------------------------------ watching
247
248 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
249 const hit = bashFrozenHit(frozen, e.command)
250
251 if (hit !== null && !(await isAllowed($, `run a command that writes ${hit}`))) {
252 return { deny: denyText(hit) }
253 }
254
255 const check = checkOf(await read($, ledger), e.command)
256 const started = await $.clock.now()
257 const ran = await next(e)
258
259 if (ran.deny !== undefined) return ran
260
261 const ms = (await $.clock.now()) - started
262 const record = ran.isError === true ? undefined : ran.result
263 const isBackground = record?.backgroundTaskId !== undefined
264
265 if (check !== null) {
266 await update($, ledger, was =>
267 recordRun(was, {
268 id: e.tool_use_id,
269 command: e.command,
270 ok: ran.isError !== true && record?.interrupted !== true,
271 exit: ran.isError === true ? exitOf(ran.text) : null,
272 ms,
273 masked: isBackground ? 'running in the background' : maskOf(e.command),
274 gate: check.gate,
275 }),
276 )
277 await settle($, e.tool_use_id)
278 } else {
279 // A shell command that changed files stales a pass as an edit does.
280 const changed = (record?.bashEditDiff?.files ?? [])
281 .map(file => relative(root, file.filePath))
282 .filter(path => !isJunk(path))
283 .slice(0, 20)
284
285 for (const path of changed) {
286 await update($, ledger, was => recordEdit(was, { id: e.tool_use_id, path }))
287 }
288 }
289
290 return ran
291 }).catch(($, e, next) => {
292 if (next.called) return next(e)
293
294 const hit = bashFrozenHit(frozen, e.command)
295
296 return hit === null ? next(e) : { deny: denyText(hit) }
297 })
298
299 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
300 const path = relative(root, e.file_path)
301
302 if (isFrozen(frozen, path)) {
303 const change =
304 e.tool === 'Edit'
305 ? ` ("${clip(e.old_string.trim(), 40)}" to "${clip(e.new_string.trim(), 40)}")`
306 : ''
307
308 if (!(await isAllowed($, `edit ${path}${change}`))) return { deny: denyText(path) }
309 }
310
311 const ran = await next(e)
312
313 if (ran.deny !== undefined || ran.isError === true) return ran
314
315 await update($, ledger, was => recordEdit(was, { id: e.tool_use_id, path }))
316
317 if (path === CONFIG) await load($)
318
319 return ran
320 }).catch(($, e, next) => {
321 if (next.called) return next(e)
322
323 const path = relative(root, e.file_path)
324
325 return isFrozen(frozen, path) ? { deny: denyText(path) } : next(e)
326 })
327
328 on('turn.start', async ($, e, next) => {
329 await update($, ledger, was => ({ ...was, turn: was.turn + 1 }))
330
331 return next(e)
332 })
333
334 on('turn.complete', async ($, e, next) => {
335 const done = await next(e)
336
337 if (e.agentId !== undefined || e.reason !== 'answer') return done
338
339 const found = claimsOf(await read($, ledger), e.answer)
340
341 if (found.length === 0) return done
342
343 await update($, ledger, was => addClaims(was, found))
344
345 const line = turnLine(found)
346
347 return { ...done, text: done.text === e.answer ? line : `${done.text}\n${line}` }
348 })
349
350 // ------------------------------------------------------------- drawing
351
352 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
353 const now = await read($, ledger)
354 const mine = now.runs.filter(run => run.turn === now.turn)
355
356 if (mine.length === 0) return next(e)
357
358 const failing = mine.filter(run => !run.ok).length
359 const counts = `${mine.length} ${mine.length === 1 ? 'check' : 'checks'}`
360 const suffix = ` · ${counts}${failing > 0 ? ` · ${failing} failing` : ''}…`
361
362 return next({ ...e, props: { ...e.props, suffix } })
363 })
364
365 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
366 const now = await read($, ledger)
367 const id = e.props.tool_use_id
368 const run = now.runs.find(one => one.id === id)
369 const staled = now.edits.filter(one => one.id === id).flatMap(one => one.staled)
370
371 if (run === undefined && staled.length === 0) return next(e)
372
373 const { Box, Text } = $.ui.resolve(e)
374 const theirs = await next(e)
375 const names = [...new Set(staled)].join(', ')
376
377 return (
378 <Box flexDirection="column">
379 {theirs}
380 <Box flexDirection="row" columnGap={1} paddingLeft={5}>
381 <Text inverse> attest </Text>
382 {run !== undefined ? (
383 <Text color={runTone(run)}>{runLine(run)}</Text>
384 ) : (
385 <Text color="warning">{names} now STALE: changed after its last pass</Text>
386 )}
387 </Box>
388 </Box>
389 )
390 })
391
392 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
393 const summary = summarize(await read($, ledger))
394
395 if (e.props.hasSurvey || summary === null) return next(e)
396
397 const { Box, Button, Text } = $.ui.resolve(e)
398 const theirs = await next(e)
399
400 return (
401 <Box flexDirection="column">
402 {theirs}
403 <Box flexDirection="row" columnGap={2}>
404 <Text inverse> attest </Text>
405 {summary.tone === null ? (
406 <Text dimColor>{summary.head}</Text>
407 ) : (
408 <Text color={summary.tone}>{summary.head}</Text>
409 )}
410 <Text dimColor wrap="truncate-end">
411 {summary.tail}
412 </Text>
413 <Button key="open" label="ledger" hotkey="l" plain onPress={() => openPane($)} />
414 </Box>
415 </Box>
416 )
417 })
418
419 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
420 const { Box, Button, Text } = $.ui.resolve(e)
421 const now = await read($, ledger)
422 const shown = await read($, tab)
423 const width = Math.max(20, e.props.bodyColumns - 12)
424
425 const tabButton = (to: Tab, label: string, hotkey: string) => (
426 <Button
427 key={`tab-${to}`}
428 label={label}
429 hotkey={hotkey}
430 plain
431 dimColor={shown !== to}
432 onPress={() => update($, tab, () => to)}
433 />
434 )
435
436 const gates = (
437 <Box flexDirection="column" rowGap={1}>
438 {now.configError !== null && (
439 <Text color="error">
440 {CONFIG}: {now.configError}
441 </Text>
442 )}
443 {!now.hasConfig && (
444 <Box flexDirection="column">
445 <Text>No gates declared.</Text>
446 <Text dimColor>/attest init writes a starter {CONFIG}.</Text>
447 <Text dimColor>Test commands Claude runs are tracked on the Runs tab either way.</Text>
448 </Box>
449 )}
450 {now.gates.map(gate => {
451 const { label, tone } = gateLabel(gate)
452 const note = gateNote(gate)
453
454 return (
455 <Box flexDirection="row" columnGap={2}>
456 <Box width={8} flexShrink={0}>
457 {tone === null ? <Text dimColor>{label}</Text> : <Text color={tone}>{label}</Text>}
458 </Box>
459 <Box flexDirection="column">
460 <Text>{gate.name}</Text>
461 <Text dimColor>{clip(gate.command, width)}</Text>
462 {note.isWarning ? (
463 <Text color="warning">{clip(note.text, width)}</Text>
464 ) : (
465 <Text dimColor>{clip(note.text, width)}</Text>
466 )}
467 </Box>
468 </Box>
469 )
470 })}
471 {now.gates.length > 0 && (
472 <Box flexDirection="row" columnGap={2}>
473 <Button
474 key="run-stale"
475 label="r: run what is not green"
476 hotkey="r"
477 onPress={() => runGates($, 'stale')}
478 />
479 <Button key="run-all" label="a: run all" hotkey="a" onPress={() => runGates($, 'all')} />
480 </Box>
481 )}
482 {now.hasConfig && (
483 <Text dimColor>
484 {CONFIG} is frozen to Claude{now.frozen.length > 1 ? `, with ${now.frozen.length - 1} more` : ''}
485 </Text>
486 )}
487 </Box>
488 )
489
490 const claims = (
491 <Box flexDirection="column" rowGap={1}>
492 {now.claims.length === 0 && (
493 <Box flexDirection="column">
494 <Text>No claims yet.</Text>
495 <Text dimColor>A claim is a sentence in an answer that says something passed.</Text>
496 </Box>
497 )}
498 {now.claims
499 .slice(-8)
500 .reverse()
501 .map(claim => (
502 <Box flexDirection="row" columnGap={2}>
503 <Box width={8} flexShrink={0}>
504 <Text color={verdictTone(claim.verdict)}>{claim.verdict.toUpperCase()}</Text>
505 </Box>
506 <Box flexDirection="column">
507 <Text>"{clip(claim.text, width * 2)}"</Text>
508 <Text dimColor>{clip(claim.why, width * 2)}</Text>
509 </Box>
510 </Box>
511 ))}
512 </Box>
513 )
514
515 const rows = [
516 ...now.runs.map(run => ({ order: run.order, run, edit: undefined })),
517 ...now.edits.map(edit => ({ order: edit.order, run: undefined, edit })),
518 ]
519 .sort((a, b) => b.order - a.order)
520 .slice(0, 12)
521
522 const runs = (
523 <Box flexDirection="column" rowGap={1}>
524 {rows.length === 0 && <Text dimColor>Nothing watched yet.</Text>}
525 {rows.map(row =>
526 row.run !== undefined ? (
527 <Box flexDirection="column">
528 <Box flexDirection="row" columnGap={2}>
529 <Text>#{row.run.n}</Text>
530 <Text color={runTone(row.run)}>
531 {row.run.masked !== null ? 'HIDDEN' : row.run.ok ? 'PASS' : 'FAIL'}
532 </Text>
533 <Text dimColor>
534 {seconds(row.run.ms)}
535 {row.run.exit !== null ? ` · exit ${row.run.exit}` : ''}
536 {row.run.gate !== null ? ` · ${row.run.gate}` : ''}
537 </Text>
538 </Box>
539 <Text dimColor>{clip(row.run.command, width + 8)}</Text>
540 </Box>
541 ) : (
542 <Box flexDirection="column">
543 <Box flexDirection="row" columnGap={2}>
544 <Text dimColor>edit</Text>
545 <Text>{clip(row.edit.path, width)}</Text>
546 </Box>
547 {row.edit.staled.length > 0 && (
548 <Text color="warning">stales {row.edit.staled.join(', ')}</Text>
549 )}
550 </Box>
551 ),
552 )}
553 <Text dimColor>Seen by the host, not reported by the model.</Text>
554 </Box>
555 )
556
557 return (
558 <Box flexDirection="column" rowGap={1}>
559 <Box flexDirection="row" columnGap={3}>
560 {tabButton('gates', 'Gates', '1')}
561 {tabButton('claims', 'Claims', '2')}
562 {tabButton('runs', 'Runs', '3')}
563 </Box>
564 {shown === 'gates' ? gates : shown === 'claims' ? claims : runs}
565 </Box>
566 )
567 })
568}
569hooks/ledger.ts 614 lines1// The ledger's rules, with no engine in them: every function here is pure, so
2// the tests and the hooks read the same judgement.
3
4import type {
5 Claim,
6 EditRow,
7 GateState,
8 Ledger,
9 Run,
10 Verdict,
11} from '../types'
12
13export const CONFIG = '.attest.json'
14
15export const EMPTY: Ledger = {
16 order: 0,
17 runCount: 0,
18 turn: 0,
19 hasConfig: false,
20 configError: null,
21 frozen: [],
22 gates: [],
23 runs: [],
24 edits: [],
25 claims: [],
26}
27
28const KEEP = 200
29
30// ---------------------------------------------------------------- config
31
32export type GateSpec = {
33 name: string
34 command: string
35 watch: string[]
36 timeoutSec: number
37}
38
39export type Config = { gates: GateSpec[]; frozen: string[] }
40
41const isRecord = (value: unknown): value is Record<string, unknown> =>
42 typeof value === 'object' && value !== null && !Array.isArray(value)
43
44const isStrings = (value: unknown): value is string[] =>
45 Array.isArray(value) && value.every(one => typeof one === 'string')
46
47/** Reads `.attest.json`; answers the config, or one line saying what is wrong. */
48export function parseConfig(text: string): Config | string {
49 let data: unknown
50
51 try {
52 data = JSON.parse(text)
53 } catch {
54 return 'not valid JSON'
55 }
56
57 if (!isRecord(data)) return 'expected an object with "gates"'
58
59 const rawGates = data.gates ?? []
60 const rawFrozen = data.frozen ?? []
61
62 if (!Array.isArray(rawGates)) return '"gates" must be a list'
63 if (!isStrings(rawFrozen)) return '"frozen" must be a list of paths'
64
65 const gates: GateSpec[] = []
66
67 for (const raw of rawGates) {
68 if (!isRecord(raw)) return 'each gate must be an object'
69
70 const { name, command, watch, timeoutSec } = raw
71
72 if (typeof name !== 'string' || !/^[\w.-]{1,64}$/.test(name)) {
73 return 'a gate name must be 1-64 letters, digits, "_", "." or "-"'
74 }
75 if (typeof command !== 'string' || command.trim() === '') {
76 return `gate ${name} needs a "command"`
77 }
78 if (watch !== undefined && !isStrings(watch)) {
79 return `gate ${name}: "watch" must be a list of paths`
80 }
81 if (
82 timeoutSec !== undefined &&
83 (typeof timeoutSec !== 'number' || timeoutSec < 1 || timeoutSec > 600)
84 ) {
85 return `gate ${name}: "timeoutSec" must be between 1 and 600`
86 }
87 if (gates.some(one => one.name === name)) return `gate ${name} is declared twice`
88
89 gates.push({
90 name,
91 command: squash(command),
92 watch: watch ?? [],
93 timeoutSec: timeoutSec ?? 120,
94 })
95 }
96
97 return { gates, frozen: rawFrozen.map(one => one.replace(/^\.\//, '')) }
98}
99
100/**
101 * Lays a config over the ledger: a gate that is still declared with the same
102 * command keeps what was seen of it, the rest start as not run.
103 */
104export function applyConfig(
105 ledger: Ledger,
106 config: Config | null,
107 seenRed: Readonly<Record<string, boolean>>,
108): Ledger {
109 if (config === null) {
110 return { ...ledger, hasConfig: false, configError: null, frozen: [], gates: [] }
111 }
112
113 const gates = config.gates.map((spec): GateState => {
114 const was = ledger.gates.find(
115 one => one.name === spec.name && one.command === spec.command,
116 )
117
118 return {
119 name: spec.name,
120 command: spec.command,
121 watch: spec.watch,
122 timeoutSec: spec.timeoutSec,
123 status: was?.status ?? 'not-run',
124 run: was?.run ?? null,
125 seenRed: (was?.seenRed ?? false) || seenRed[spec.name] === true,
126 staleBy: was?.staleBy ?? null,
127 isRunning: false,
128 }
129 })
130
131 return {
132 ...ledger,
133 hasConfig: true,
134 configError: null,
135 frozen: [CONFIG, ...config.frozen.filter(one => one !== CONFIG)],
136 gates,
137 }
138}
139
140// --------------------------------------------------------------- commands
141
142export const squash = (text: string): string => text.replace(/\s+/g, ' ').trim()
143
144const TESTLIKE = new RegExp(
145 [
146 String.raw`\bpytest\b`,
147 String.raw`\bpy\.test\b`,
148 String.raw`\bpython3?\s+-m\s+(pytest|unittest)\b`,
149 String.raw`\b(tox|nox)\b`,
150 String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|check)\b`,
151 String.raw`\bnpx\s+(jest|vitest|tsc|eslint|playwright)\b`,
152 String.raw`\b(jest|vitest|mocha)\b`,
153 String.raw`\bcargo\s+(test|check|clippy)\b`,
154 String.raw`\bgo\s+(test|vet)\b`,
155 String.raw`\bmake\s+(test|check|lint|gates?|verify)\b`,
156 String.raw`(\bmvn|\bgradle|\./gradlew)\s+(test|verify|check)\b`,
157 String.raw`\btsc\b`,
158 String.raw`\b(ruff|mypy|eslint|flake8)\b`,
159 String.raw`\b(rspec|phpunit|ctest)\b`,
160 String.raw`\b(dotnet|swift)\s+test\b`,
161 String.raw`\bclaude\s+plugin\s+(test|validate)\b`,
162 ].join('|'),
163)
164
165const NOT_A_RUN =
166 /^\s*(cat|grep|rg|ls|echo|which|head|tail|sed|awk|find|cd|export|git)\b|\b(install|uninstall|add|remove)\b/
167
168/** True when some part of the command runs tests, a linter or a type check. */
169export function isTestLike(command: string): boolean {
170 return command
171 .split(/&&|\|\||[;|\n]/)
172 .some(part => TESTLIKE.test(part) && !NOT_A_RUN.test(part))
173}
174
175/** The declared gate this command runs: the longest whose command it holds. */
176export function matchGate(
177 gates: readonly GateState[],
178 command: string,
179): GateState | null {
180 const flat = squash(command)
181 let best: GateState | null = null
182
183 for (const gate of gates) {
184 if (flat.includes(gate.command) && gate.command.length > (best?.command.length ?? 0)) {
185 best = gate
186 }
187 }
188
189 return best
190}
191
192/** What makes this command a check: its gate, or null for a test-like one. */
193export function checkOf(
194 ledger: Ledger,
195 command: string,
196): { gate: string | null } | null {
197 const gate = matchGate(ledger.gates, command)
198
199 if (gate !== null) return { gate: gate.name }
200
201 return isTestLike(command) ? { gate: null } : null
202}
203
204/**
205 * What in the command hides its exit code from whoever reads it, or null:
206 * `|| true` and its kin, `set +e`, or a pipe with no `pipefail`.
207 */
208export function maskOf(command: string): string | null {
209 const swallowed = /\|\|\s*(true|:|exit\s+0|echo\b[^;&|]*)\s*(?=$|[;)&\n])/.exec(command)
210
211 if (swallowed !== null) return squash(swallowed[0])
212 if (/;\s*(true|exit\s+0)\s*$/.test(command)) return 'a trailing "true"'
213 if (/\bset\s+\+e\b/.test(command)) return 'set +e'
214 if (/(^|[^|])\|(?!\|)/.test(command) && !/pipefail/.test(command)) {
215 return 'a pipe without pipefail'
216 }
217
218 return null
219}
220
221/** The exit code a failed Bash call reported in the text the model read. */
222export function exitOf(text: string | undefined): number | null {
223 const found = /Exit code:? (\d+)/i.exec(text ?? '')
224
225 return found?.[1] === undefined ? null : Number(found[1])
226}
227
228// ------------------------------------------------------------------ paths
229
230export function relative(root: string, path: string): string {
231 const base = root.endsWith('/') ? root : `${root}/`
232
233 return root !== '' && path.startsWith(base) ? path.slice(base.length) : path
234}
235
236const JUNK =
237 /(^|\/)(\.git|node_modules|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|venv|target|dist|build|coverage)\//
238
239/** A path no gate should go stale over: caches, builds, the repository's own. */
240export const isJunk = (path: string): boolean => JUNK.test(path) || path.endsWith('.pyc')
241
242export function isFrozen(frozen: readonly string[], path: string): boolean {
243 return frozen.some(one => path === one || path.endsWith(`/${one}`))
244}
245
246const WRITES =
247 /\bsed\s+[^|;&]*-i|\btee\b|\bmv\b|\brm\b|\bcp\b|\btruncate\b|\bpatch\b|\bgit\s+(checkout|restore|apply)\b|\b(python3?|node|perl|ruby)\s+-\w*[ce]\b/
248
249const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
250
251/** The frozen file a shell command looks set to write, or null. */
252export function bashFrozenHit(frozen: readonly string[], command: string): string | null {
253 for (const one of frozen) {
254 const base = one.split('/').pop() ?? one
255
256 if (!command.includes(base)) continue
257
258 const redirect = new RegExp(String.raw`>{1,2}\s*["']?[^\s"'|;&]*${escapeRegExp(base)}`)
259
260 if (redirect.test(command) || WRITES.test(command)) return one
261 }
262
263 return null
264}
265
266function touches(gate: GateState, path: string): boolean {
267 if (gate.watch.length === 0) return true
268
269 return gate.watch.some(pattern => {
270 if (pattern.startsWith('*.')) return path.endsWith(pattern.slice(1))
271
272 const folder = pattern.endsWith('/') ? pattern : `${pattern}/`
273
274 return path === pattern || path.startsWith(folder)
275 })
276}
277
278// -------------------------------------------------------------- recording
279
280export type RunInput = {
281 id: string
282 command: string
283 ok: boolean
284 exit: number | null
285 ms: number
286 masked: string | null
287 gate: string | null
288}
289
290/** Adds one watched check; a masked one changes no gate. */
291export function recordRun(ledger: Ledger, input: RunInput): Ledger {
292 const run: Run = {
293 n: ledger.runCount + 1,
294 order: ledger.order + 1,
295 id: input.id,
296 turn: ledger.turn,
297 ms: Math.max(0, Math.round(input.ms)),
298 command: squash(input.command).slice(0, 400),
299 ok: input.ok,
300 exit: input.exit,
301 masked: input.masked,
302 gate: input.gate,
303 }
304
305 const gates = ledger.gates.map((gate): GateState => {
306 if (gate.name !== input.gate) return gate
307 if (input.masked !== null) return { ...gate, isRunning: false }
308
309 return {
310 ...gate,
311 status: input.ok ? 'pass' : 'fail',
312 run: run.n,
313 seenRed: gate.seenRed || !input.ok,
314 staleBy: null,
315 isRunning: false,
316 }
317 })
318
319 return {
320 ...ledger,
321 order: run.order,
322 runCount: run.n,
323 gates,
324 runs: [...ledger.runs, run].slice(-KEEP),
325 }
326}
327
328/** Adds one watched file change; every passing gate it touches goes stale. */
329export function recordEdit(
330 ledger: Ledger,
331 input: { id: string; path: string },
332): Ledger {
333 const staled: string[] = []
334
335 const gates = ledger.gates.map((gate): GateState => {
336 if (gate.status !== 'pass' || !touches(gate, input.path)) return gate
337
338 staled.push(gate.name)
339
340 return { ...gate, status: 'stale', staleBy: input.path }
341 })
342
343 const edit: EditRow = {
344 order: ledger.order + 1,
345 id: input.id,
346 turn: ledger.turn,
347 path: input.path,
348 staled,
349 }
350
351 return {
352 ...ledger,
353 order: edit.order,
354 gates,
355 edits: [...ledger.edits, edit].slice(-KEEP),
356 }
357}
358
359export function setRunning(ledger: Ledger, name: string, isRunning: boolean): Ledger {
360 return {
361 ...ledger,
362 gates: ledger.gates.map(gate => (gate.name === name ? { ...gate, isRunning } : gate)),
363 }
364}
365
366// ----------------------------------------------------------------- claims
367
368const HEDGE =
369 /\b(not|never|no longer|cannot|can't|couldn't|didn't|doesn't|don't|haven't|hasn't|isn't|aren't|wasn't|won't|fail(s|ed|ing|ure|ures)?|broken|should|would|could|might|may|will|once|if|unless|until|need(s|ed)? to|want(s|ed)? to|try|trying|to make|to get|expect(s|ed)?|todo|next steps?|please)\b|\?\s*$/i
370
371const SUCCESS = /\b(pass(es|ed|ing)?|green|clears?|cleared|succeed(s|ed)?|meets?|met)\b/i
372
373const CLAIM = new RegExp(
374 [
375 String.raw`\b(tests?|specs?|suite|checks?|gates?|build|lint|linter|type ?checks?|ci)\b[^.!?\n]{0,50}\b(pass(es|ed|ing)?|green|succeed(s|ed)?|clean)\b`,
376 String.raw`\b(passing|green)\s+(tests?|build|suite|ci|checks?)\b`,
377 String.raw`\beverything\s+(passes|is green|works)\b`,
378 String.raw`\b(verified|confirmed)\b`,
379 String.raw`\bclears?\s+the\s+(floor|bar|threshold)\b`,
380 String.raw`\b(passes|passing|green)\s+(now|again)\b`,
381 ].join('|'),
382 'i',
383)
384
385function sentencesOf(answer: string): string[] {
386 return answer
387 .replace(/```[\s\S]*?```/g, ' ')
388 .split(/\n+|(?<=[.!?])\s+/)
389 .map(one => one.replace(/^[\s>#*\-•\d.)]+/, '').replace(/[*_`]/g, '').trim())
390 .filter(one => one.length >= 8)
391 .slice(0, 60)
392}
393
394function mentions(sentence: string, name: string): boolean {
395 const text = sentence.toLowerCase()
396 const lower = name.toLowerCase()
397 const words = lower.split(/[_.-]+/).filter(word => word.length >= 3)
398
399 return text.includes(lower) || (words.length > 0 && words.every(word => text.includes(word)))
400}
401
402function lastEditAfter(ledger: Ledger, order: number): EditRow | undefined {
403 return ledger.edits.findLast(edit => edit.order > order && !isJunk(edit.path))
404}
405
406type Judgement = { verdict: Verdict; why: string }
407
408function judgeGate(gate: GateState): Judgement {
409 switch (gate.status) {
410 case 'pass':
411 return { verdict: 'backed', why: `run #${gate.run} passed, nothing it watches changed since` }
412 case 'stale':
413 return { verdict: 'stale', why: `${gate.staleBy} changed after run #${gate.run} passed` }
414 case 'fail':
415 return { verdict: 'unbacked', why: `run #${gate.run} failed` }
416 case 'not-run':
417 return { verdict: 'unbacked', why: `${gate.name} has not run this session` }
418 }
419}
420
421function judgeGeneric(ledger: Ledger): Judgement {
422 const last = ledger.runs.at(-1)
423
424 if (last === undefined) {
425 return { verdict: 'unbacked', why: 'no test or gate command has run this session' }
426 }
427 if (last.masked !== null) {
428 return { verdict: 'unbacked', why: `run #${last.n} hid its exit code (${last.masked})` }
429 }
430 if (!last.ok) return { verdict: 'unbacked', why: `run #${last.n} failed` }
431
432 const failing = ledger.gates.find(gate => gate.status === 'fail')
433
434 if (failing !== undefined) {
435 return { verdict: 'unbacked', why: `gate ${failing.name} is failing` }
436 }
437
438 const edit = lastEditAfter(ledger, last.order)
439
440 if (edit !== undefined) {
441 return { verdict: 'stale', why: `${edit.path} changed after run #${last.n} passed` }
442 }
443
444 return { verdict: 'backed', why: `run #${last.n} passed, nothing changed since` }
445}
446
447/**
448 * The success claims in an answer, each judged against what the host saw.
449 *
450 * Deliberately narrow: a hedged, negated, conditional or future sentence is
451 * no claim, so a miss is likelier than a false flag.
452 */
453export function claimsOf(ledger: Ledger, answer: string): Claim[] {
454 const claims: Claim[] = []
455
456 for (const sentence of sentencesOf(answer)) {
457 if (HEDGE.test(sentence)) continue
458
459 const gate = ledger.gates.find(one => mentions(sentence, one.name))
460 let judged: Judgement | null = null
461
462 if (gate !== undefined && SUCCESS.test(sentence)) judged = judgeGate(gate)
463 else if (CLAIM.test(sentence)) judged = judgeGeneric(ledger)
464
465 if (judged === null) continue
466
467 const text = sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence
468
469 if (claims.some(one => one.text === text)) continue
470
471 claims.push({ turn: ledger.turn, text, ...judged })
472
473 if (claims.length === 8) break
474 }
475
476 return claims
477}
478
479export function addClaims(ledger: Ledger, claims: readonly Claim[]): Ledger {
480 return { ...ledger, claims: [...ledger.claims, ...claims].slice(-100) }
481}
482
483// ------------------------------------------------------------------ words
484
485const plural = (count: number, word: string): string =>
486 `${count} ${word}${count === 1 ? '' : 's'}`
487
488export const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
489
490export const clip = (text: string, width: number): string =>
491 text.length > width ? `${text.slice(0, Math.max(1, width - 3))}...` : text
492
493/** The line shown under an answer that made claims. */
494export function turnLine(claims: readonly Claim[]): string {
495 const count = (verdict: Verdict): number =>
496 claims.filter(claim => claim.verdict === verdict).length
497 const stale = count('stale')
498 const unbacked = count('unbacked')
499
500 if (stale + unbacked === 0) return `attest: ${plural(claims.length, 'claim')} · all backed`
501
502 return [
503 `attest: ${plural(claims.length, 'claim')}`,
504 `${count('backed')} backed`,
505 ...(stale > 0 ? [`${stale} stale`] : []),
506 ...(unbacked > 0 ? [`${unbacked} unbacked`] : []),
507 ].join(' · ')
508}
509
510/** How a watched run reads: its outcome first, then what it moved. */
511export function runLine(run: Run): string {
512 const outcome = run.ok ? 'ok' : run.exit !== null ? `exit ${run.exit}` : 'failed'
513 const parts = [outcome, seconds(run.ms)]
514
515 if (run.masked !== null) {
516 parts.push(`exit code hidden by ${run.masked}`)
517 parts.push(run.gate !== null ? `gate ${run.gate} NOT COUNTED` : 'NOT COUNTED')
518 } else if (run.gate !== null) {
519 parts.push(`gate ${run.gate} ${run.ok ? 'PASS' : 'FAIL'}`)
520 }
521
522 parts.push(`run #${run.n}`)
523
524 return parts.join(' · ')
525}
526
527export type Tone = 'success' | 'warning' | 'error'
528
529export const runTone = (run: Run): Tone =>
530 run.masked !== null ? 'warning' : run.ok ? 'success' : 'error'
531
532export const verdictTone = (verdict: Verdict): Tone =>
533 verdict === 'backed' ? 'success' : verdict === 'stale' ? 'warning' : 'error'
534
535export function gateLabel(gate: GateState): { label: string; tone: Tone | null } {
536 if (gate.isRunning) return { label: 'RUNNING', tone: null }
537
538 switch (gate.status) {
539 case 'pass':
540 return { label: 'PASS', tone: 'success' }
541 case 'fail':
542 return { label: 'FAIL', tone: 'error' }
543 case 'stale':
544 return { label: 'STALE', tone: 'warning' }
545 case 'not-run':
546 return { label: 'NOT RUN', tone: null }
547 }
548}
549
550/** The second line under a gate: why its status is what it is. */
551export function gateNote(gate: GateState): { text: string; isWarning: boolean } {
552 switch (gate.status) {
553 case 'pass':
554 return gate.seenRed
555 ? { text: `run #${gate.run} · has been seen failing`, isWarning: false }
556 : { text: `run #${gate.run} · never seen red, unproven`, isWarning: true }
557 case 'stale':
558 return { text: `${gate.staleBy} changed after run #${gate.run}`, isWarning: true }
559 case 'fail':
560 return { text: `run #${gate.run} failed`, isWarning: false }
561 case 'not-run':
562 return { text: 'not run this session', isWarning: false }
563 }
564}
565
566export type Summary = { tone: Tone | null; head: string; tail: string }
567
568/** The band's one line: the worst thing first; null while there is nothing to say. */
569export function summarize(ledger: Ledger): Summary | null {
570 if (ledger.configError !== null) {
571 return { tone: 'error', head: `✗ ${CONFIG} is not usable`, tail: ledger.configError }
572 }
573 if (!ledger.hasConfig && ledger.runs.length === 0 && ledger.claims.length === 0) return null
574
575 const passing = ledger.gates.filter(gate => gate.status === 'pass')
576 const unproven = passing.filter(gate => !gate.seenRed).length
577 const tail = ledger.hasConfig
578 ? `gates ${passing.length}/${ledger.gates.length} green${unproven > 0 ? ` · ${unproven} unproven` : ''}`
579 : `${plural(ledger.runs.length, 'check')} seen`
580
581 const failing = ledger.gates.filter(gate => gate.status === 'fail')
582 const [firstFailing] = failing
583
584 if (firstFailing !== undefined) {
585 const more = failing.length > 1 ? ` +${failing.length - 1} more` : ''
586
587 return { tone: 'error', head: `✗ ${firstFailing.name} FAIL${more}`, tail }
588 }
589
590 const last = ledger.runs.at(-1)
591
592 if (last !== undefined && !last.ok && last.masked === null) {
593 return { tone: 'error', head: `✗ run #${last.n} failed`, tail }
594 }
595
596 const now = ledger.claims.filter(claim => claim.turn === ledger.turn)
597 const unbacked = now.filter(claim => claim.verdict === 'unbacked').length
598 const stale = now.filter(claim => claim.verdict === 'stale').length
599 const staleGates = ledger.gates.filter(gate => gate.status === 'stale').length
600 const parts = [
601 ...(unbacked > 0 ? [`${unbacked} unbacked`] : []),
602 ...(stale > 0 ? [`${stale} stale`] : []),
603 ...(unbacked + stale === 0 && staleGates > 0 ? [`${plural(staleGates, 'gate')} stale`] : []),
604 ]
605
606 if (parts.length > 0) return { tone: 'warning', head: `▲ ${parts.join(' · ')}`, tail }
607 if (now.length > 0) return { tone: 'success', head: '✓ all claims backed', tail }
608 if (last?.ok === true && last.masked === null) {
609 return { tone: 'success', head: `✓ run #${last.n} passed`, tail }
610 }
611
612 return { tone: null, head: 'nothing checked yet', tail }
613}
614types/index.d.ts 80 lines1export type GateStatus = 'not-run' | 'pass' | 'fail' | 'stale'
2
3/** One declared gate and what the host last saw of it. */
4export type GateState = {
5 name: string
6 command: string
7 /** Paths whose edits stale this gate; empty means any edit does. */
8 watch: string[]
9 timeoutSec: number
10 status: GateStatus
11 /** The run that set the status, or null before any. */
12 run: number | null
13 /** True once this gate has been seen failing, in this session or an earlier one. */
14 seenRed: boolean
15 /** The file whose edit staled a pass, or null. */
16 staleBy: string | null
17 /** True while a rerun the person asked for is in flight. */
18 isRunning: boolean
19}
20
21/** One check the host watched: a gate command or a test-like command. */
22export type Run = {
23 n: number
24 /** Place in the session's order of runs and edits. */
25 order: number
26 /** The tool call's id, or `direct-<n>` for a rerun from the pane. */
27 id: string
28 turn: number
29 ms: number
30 command: string
31 ok: boolean
32 exit: number | null
33 /** What hid the exit code, or null when nothing did. */
34 masked: string | null
35 gate: string | null
36}
37
38/** One file change the host watched. */
39export type EditRow = {
40 order: number
41 id: string
42 turn: number
43 path: string
44 /** Gates this edit turned from pass to stale. */
45 staled: string[]
46}
47
48export type Verdict = 'backed' | 'stale' | 'unbacked'
49
50/** One success claim found in an answer, judged against the ledger. */
51export type Claim = {
52 turn: number
53 text: string
54 verdict: Verdict
55 why: string
56}
57
58export type Ledger = {
59 /** Count of runs and edits so far. */
60 order: number
61 /** Count of runs so far. */
62 runCount: number
63 turn: number
64 hasConfig: boolean
65 configError: string | null
66 frozen: string[]
67 gates: GateState[]
68 runs: Run[]
69 edits: EditRow[]
70 claims: Claim[]
71}
72
73export type Tab = 'gates' | 'claims' | 'runs'
74
75declare module 'claude-code' {
76 interface PluginState {
77 'attest-ledger': { ledger: Ledger; tab: Tab }
78 }
79}
80