SLOPSHOPPER

Attest

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

newpanebandspinnerrowsguard
v0.1.2MITupdated 2026-10-08ramankrishna/attest-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · attest-ledger
│ ┃ Attest ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Gates 2: Claims 3: Runs │ ┃ ⏺ Read(src/auth.ts) │ ┃ No gates declared. ⎿ Read 6 lines │ ┃ /attest init writes a starter .attest.json. ⏺ Update(src/auth.ts) │ ┃ Test commands Claude runs are tracked on the ⎿ Added 2 lines, removed 1 line │ ┃ Runs tab either way. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /attest │ │ ✻ Thinking · 1 check · 1 failing…… ⟨Claude Code's own drawing⟩ attest ✗ run #1 failed 1 check seen l: ledger ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ attest ✗ run #1 failed 1 check seen l: ledger
Pane · Attest
1: Gates 2: Claims 3: Runs No gates declared. /attest init writes a starter .attest.json. Test commands Claude runs are tracked on the Runs tab either way.
README

Attest

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.

What you see

  • Under a test run: one line with the outcome, the time it took, and the gate it moved.
  • Under an answer: a count of the success claims in it, and how many are backed, stale or unbacked.
  • Above the prompt: the worst thing first, then how many gates are green.
  • /attest: a pane with three tabs. Gates, Claims and Runs.
  • A question before Claude changes a file you froze.

The three verdicts

| 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. |

Install

/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.

Use it without any setup

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.

Declare your own gates

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.

Things it checks that are easy to miss

  • Hidden exit codes. pytest || true, set +e, and pytest | tail without pipefail all report success whatever the tests did. Such a run is shown and not counted.
  • Gates that have never failed. A gate that has only ever passed is marked unproven until it has been seen failing once. That history is kept between sessions.
  • Edits after a pass. Changes made with the Edit and Write tools, and file changes a shell command reports, both make a pass stale.

What it runs, reads and stores

  • Reads .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.
  • Writes .attest.json once, only when you run /attest init and the file does not exist.
  • Runs a gate's 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.
  • Stores one flag per gate, on your machine, saying it has been seen failing.
  • Sends nothing. No network calls, no model calls, no telemetry.

Like every mod, it runs with the same access to your machine as Claude Code itself.

The full statement is in PRIVACY.md.

Limits

  • Claim detection is a set of patterns over the answer's text. It is deliberately narrow: hedged, negated and future sentences are skipped, so it misses more than it flags.
  • Claude Code reports that a shell command failed, not always its exit code. Attest shows the code when the result names it and "failed" otherwise.
  • The guard on frozen files covers the Edit and Write tools and obvious shell writes such as redirects, sed -i, mv and rm. A determined script can still get past it.
  • A command that runs in the background is shown and not counted, since its result is not known when the call returns.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.tsx 569 lines
1// 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}
569
hooks/ledger.ts 614 lines
1// 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}
614
types/index.d.ts 80 lines
1export 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