Claude says the tests pass. Receipts checks. Flags success claims that no check backs up.

Claude says the tests pass. Receipts checks.
A Claude Code mod that flags turns where Claude claims success without a check to back it up. This page is the reference. For the overview, install and demo, see the Notary README.
/plugin install receipts --marketplace candygoogy/notary
Needs Claude Code 2.1.287 or later. On an older version, Receipts shows one notice and stays off.
At the end of each turn, Receipts judges the final answer only if both of these hold:
So "Done." after answering a question gets no verdict. Neither does "Committed." right after a turn whose tests passed.
An Edit, Write or NotebookEdit that landed counts when it changes code in the project. These don't count, because they can't break a test:
.md files, CLAUDE.md, memory files, .claude/ and .agent/;target/, dist/ and node_modules/;So "tests passed, then Claude updated STATUS.md and said *All tests pass*" stays ✅.
Turns that end by interruption, refusal or an API error are never judged. Neither is a subagent's own final report, but subagents' edits and test runs count toward the main conversation's claims.
| Verdict | Rule |
|---|---|
| ✅ Verified | The latest qualifying check after the last edit passed. |
| ⚠️ Unverified | No qualifying check after the last edit. Checks still running in the background, and runs that found no tests, don't qualify. |
| ❌ Contradicted | The latest qualifying check after the last edit failed. |
When an answer has several claims, the worst verdict wins.
Every verdict goes into the /receipts pane. What reaches you unasked depends on the verbosity setting.
quiet (the default):
| Verdict | Band above the prompt | Stamp under the answer | Mode B | Mode C |
|---|---|---|---|---|
| ❌ any claim | until the next verdict | yes | sent back once | sent back once |
| ⚠️ tests or build claim, code edited this turn | until the next verdict; in mode B, until Claude has answered the reminder | yes | reminder with your next prompt | sent back once |
| ⚠️ general claim, or the edit was in an earlier turn | pane only | pane only | nothing | nothing |
| ✅ tests or build claim | until your next prompt | yes | nothing | nothing |
| ✅ general claim | pane only | pane only | nothing | nothing |
Details on the band opens the /receipts pane, and shows only while the pane is shut. Dismiss takes the band down at any time; the pane keeps the verdict.
all: every verdict shows on the band and gets a stamp, and every ⚠️ sends a reminder (mode B) or sends Claude back (mode C). This is how v0.1 behaved before the replay of real sessions showed it was too noisy.
Mode A never sends a reminder or sends Claude back.
Receipts splits the answer into sentences and looks for phrases like these:
Each claim has a kind, which decides what can back it:
tsc, lint or a linter. It's judged by build, type check and lint runs. A passing test run also backs it, since the code compiled. A failing test run says nothing about the build.A claim that names an area, like "the website tests pass" or "parser tests", ignores failures from commands that don't mention that area. A Rust failure then doesn't contradict a claim about the website's tests.
A sentence is not a claim when it:
Code blocks, inline code, > quotes, double-quoted text and unchecked - [ ] items are ignored. A checked - [x] tests pass is a claim.
A sentence over 25 words that also contains "but", "although" or "except" is uncertain. It is skipped, unless smart check is on.
Every Bash and PowerShell command Claude runs is classified by what it can prove.
Test runs back any claim:
testplaywright testnode --test, bun test, deno testpython -m pytest or unittest, tox, noxcargo test and cargo nextest, go test, dotnet testgradle test or check, mvn test or verifymake test or make checkBuilds and checks back general claims:
npm run build, typecheck or lintcargo build, check or clippygo build, go vet, dotnet buildNot evidence:
prettier --check, ruff format --check, black --check, cargo fmt --check, gofmt -l, dotnet format --verify-no-changes. An unformatted file is not a broken fix.ruff check --select ALL: it turns on every rule, so it's exploratory, not a project check--version, --help, --collect-only, --no-run, --listTests, --dry-run|| true, || exit 0 or || :Runners are recognized however they're called: npx vitest, node node_modules/vitest/vitest.mjs, .\venv\Scripts\python.exe -m pytest, ~/.cargo/bin/cargo test, timeout 120 pnpm exec vitest, timeout -k 10 300 npm test, gtimeout 120 cargo test.
If your project runs its tests or build through its own command (pnpm verify, make ci, ./check.sh), tell Receipts with the checks setting. Each entry is tests: <command> or build: <command>:
/receipts check add "pnpm verify" tests
/receipts check add "make ci" build
/receipts checks
/receipts check remove "make ci"
You can also edit the list in /config. A declared check follows the same rules as the runners above: it matches its command with any arguments after it, through the same wrappers, and in chains and pipes. Its exit code or summary decides pass or fail. A failing tests: check counts like a failing test run, so it can contradict a claim and send Claude back. A declared check never weakens a runner Receipts already knows.
When a claim names the command itself ("pnpm verify passes"), it is judged as a tests or build claim by the same list.
In a chain like npm run build && npm test, the strongest check that actually ran decides, and of several, the last. If an early step fails, the steps after it never ran. When one command runs the same test runner twice, its last summary decides. That covers the common "run one test, fix, run the suite" pattern.
test result: FAILED. 30 passed; 1 failed, "Tests Passed: 3, Failed: 1"). Zero counts ("0 failed", "ℹ fail 0") are passes. Color codes are stripped, and only the last 64 KB is read.npm test | tail -5) hides the exit code. set -o pipefail restores it.npm test | Select-Object -Last 5) does not hide the native command's exit code. A later statement that runs another program does.Pester only sets an exit code with -CI, so its summary is always read.
| Setting | Values | Default | Command | ||
|---|---|---|---|---|---|
mode | A (warn), B (warn, nudge, send back on ❌), C (send back on ⚠️ or ❌) | B | `/receipts mode A\ | B\ | C` |
enabled | on or off | on | /receipts on, /receipts off | ||
smartCheck | on or off | off | `/receipts smart on\ | off` | |
verbosity | quiet (flag ❌ and unchecked tests or build claims; the rest goes to the pane) or all | quiet | `/receipts verbosity quiet\ | all` | |
stamp | on or off | on | /config | ||
checks | your own check commands, each tests: <command> or build: <command> | none | `/receipts check add <command> tests\ | build, /receipts check remove <command>, /receipts checks` |
/receipts with no arguments opens the pane. If the window is too narrow for it, the pane waits, and /receipts or the band's Details says so and why, once.
Receipts: you claimed "All tests pass.", but npm test exited 1 (1 failed). Fix it and verify before claiming success. It happens at most once per turn, and never while Claude is already continuing because of a stop hook. If another hook sends Claude back at the same moment, its reason is used and Receipts adds none.Receipts: your previous claim '…' was not verified by any check run after your last edit. Verify before claiming success. It is attached only to prompts you type (terminal, Remote Control, or claude -p), never to prompts another plugin sends.sed -i, Set-Content or a script don't reopen verification.N filtered out), put it back" check after a full pass doesn't overturn that pass.Receipts never blocks a tool, and its own errors never stop Claude. If something inside Receipts fails, the turn just isn't judged, and the reason goes to the debug log (claude --debug). If Receipts ever keeps a turn from ending when it shouldn't, /receipts off turns it off at once.
Receipts is tested on Windows with the PowerShell and Git Bash tools. The tests cover:
npm.cmd-CIdotnet test outputThe manual checklist is in docs/windows-checklist.md.
hooks/register.tsx 481 lines1import type { EngineInterface as Engine, Hook, Register } from 'claude-code'
2import { update } from 'claude-code'
3
4import { parseChecks } from './core/checks.ts'
5import { findClaims } from './core/claims.ts'
6import { stampText } from './core/copy.ts'
7import { isVerifiableEdit } from './core/edits.ts'
8import { classify } from './core/evidence.ts'
9import { editedSince, emptyLedger, recordEdit, recordRun } from './core/ledger.ts'
10import type { Claim, DeclaredCheck, Mode, Verdict } from './core/types.ts'
11import { decide, judge } from './core/verdict.ts'
12import { bandStays, visibility } from './core/visibility.ts'
13import type { Verbosity, Visibility } from './core/visibility.ts'
14import { SMART_MAX_TOKENS, SMART_MODEL, SMART_SYSTEM, isYes } from './smartcheck.ts'
15import { ARGUMENT_HINT, DESCRIPTION, USAGE, editChecks, parseCommand } from './commands.ts'
16import { waitingNote } from './shared/pane.ts'
17import { bandView } from './ui/band.tsx'
18import { paneView } from './ui/pane.tsx'
19
20// Wiring only (spec §5): events in, core calls, state and rows out. Observers
21// always hand back what `next` resolved to; a bug here means "not judged".
22
23const MIN_VERSION = [2, 1, 287] as const
24const HISTORY_LIMIT = 200
25const SENT_BACK_LIMIT = 50
26const NUDGE_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
27const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit']
28
29// State references: `plugin` and `key` must be literals in a const of this file.
30const LEDGER = { plugin: 'receipts', key: 'ledger' } as const
31const CURRENT_TURN = { plugin: 'receipts', key: 'currentTurn' } as const
32const JUDGED = { plugin: 'receipts', key: 'judged' } as const
33const SENT_BACK = { plugin: 'receipts', key: 'sentBack' } as const
34const LAST = { plugin: 'receipts', key: 'last' } as const
35const BAND_HIDDEN = { plugin: 'receipts', key: 'bandHidden' } as const
36const HISTORY = { plugin: 'receipts', key: 'history' } as const
37const PENDING_NUDGE = { plugin: 'receipts', key: 'pendingNudge' } as const
38const REMINDED = { plugin: 'receipts', key: 'reminded' } as const
39const INACTIVE = { plugin: 'receipts', key: 'inactive' } as const
40const STAMPED = { plugin: 'receipts', key: 'stamped' } as const
41
42const PANE = { id: 'receipts', title: 'Receipts' } as const
43
44type Options = { mode: Mode; stamp: boolean; enabled: boolean; smartCheck: boolean; verbosity: Verbosity; checks: DeclaredCheck[] }
45
46export const register: Register = (on, options) => {
47 const opts: Options = {
48 mode: options.mode === 'A' || options.mode === 'C' ? options.mode : 'B',
49 stamp: options.stamp !== false,
50 enabled: options.enabled !== false,
51 smartCheck: options.smartCheck === true,
52 verbosity: options.verbosity === 'all' ? 'all' : 'quiet',
53 checks: [],
54 }
55 // The person's declared checks; an entry in neither form is named once, at start.
56 const declared = parseChecks(options.checks)
57 opts.checks = declared.checks
58
59 on('session.start', async ($, e, next) => {
60 const r = await next(e)
61 try {
62 await $.command.register({ name: 'receipts', description: DESCRIPTION, argumentHint: ARGUMENT_HINT })
63 } catch (error) {
64 debug($, 'session.start', error)
65 }
66 if (!opts.enabled) return r
67 if (declared.invalid.length > 0) {
68 $.ui.toast(`Receipts ignored ${declared.invalid.length === 1 ? 'a check' : 'checks'} not written as "tests: <command>" or "build: <command>": ${declared.invalid.join('; ')}`)
69 }
70 try {
71 const { base, version } = await $.session.version()
72 if (isOlder(base ?? version, MIN_VERSION)) {
73 await $.state.set(INACTIVE, true)
74 $.ui.toast(`Receipts needs Claude Code ${MIN_VERSION.join('.')} or later; it is off for this session.`)
75 }
76 } catch (error) {
77 debug($, 'session.start', error)
78 }
79 return r
80 })
81
82 on('turn.start', async ($, e, next) => {
83 const r = await next(e)
84 if (!(await isActive($, opts))) return r
85 try {
86 const { value: ledger = emptyLedger() } = await $.state.get(LEDGER)
87 await $.state.set(CURRENT_TURN, { turnId: e.turnId, startSeq: ledger.nextSeq })
88 } catch (error) {
89 debug($, 'turn.start', error)
90 }
91 return r
92 })
93
94 on('tool.call', async ($, e, next) => {
95 const r = await next(e)
96 if (!(await isActive($, opts))) return r
97 try {
98 await record($, e, r, opts.checks)
99 } catch (error) {
100 debug($, 'tool.call', error)
101 }
102 return r
103 }).catch(($, e, next) => next(e))
104
105 // Judges the answer Claude wants to stop on. A contradicted (or, in mode C,
106 // unverified) claim is sent back once; the answer that is allowed to stand
107 // gets the stamp here, inside the turn: a row appended from turn.complete,
108 // after the turn, is not kept in a headless (-p) session's transcript.
109 on('classic.Stop', async ($, e, next) => {
110 if (!(await isActive($, opts))) return next(e)
111 let verdict: Verdict | null = null
112 let shown: Visibility | null = null
113 let sendBack: string | null = null
114 let turnId = ''
115 try {
116 const turn = (await $.state.get(CURRENT_TURN)).value ?? null
117 turnId = turn?.turnId ?? ''
118 const startSeq = turn?.startSeq ?? 0
119 verdict = await judgeOnce($, opts, turnId, startSeq, e.last_assistant_message ?? '')
120 if (verdict !== null) {
121 shown = await shownFor($, opts, verdict, startSeq)
122 const { value: sent = [] } = await $.state.get(SENT_BACK)
123 if (shown.sendBackEligible) sendBack = decide(verdict, opts.mode, sent.includes(turnId), e.stop_hook_active).sendBack
124 }
125 } catch (error) {
126 debug($, 'classic.Stop', error)
127 }
128 const r = await next(e)
129 // A settings Stop hook beneath keeps the turn going (this answer is not the
130 // last), or stops the session: either way, no send-back and no stamp.
131 if (r.block !== undefined || r.preventContinuation === true) return r
132 if (sendBack !== null) {
133 await update($, SENT_BACK, (sent = []) => [...sent, turnId].slice(-SENT_BACK_LIMIT))
134 return { ...r, block: sendBack }
135 }
136 if (verdict !== null && shown?.stamp === true && opts.stamp) {
137 try {
138 await stampOnce($, `${turnId}:${hashText(e.last_assistant_message ?? '')}`, stampText(verdict))
139 } catch (error) {
140 debug($, 'classic.Stop', error)
141 }
142 }
143 return r
144 }).catch(($, e, next) => next(e))
145
146 on('turn.complete', async ($, e, next) => {
147 const r = await next(e)
148 if (e.agentId !== undefined || !(await isActive($, opts))) return r
149 try {
150 // The turn of a prompt that carried the reminder ends, answered or not.
151 const { value: reminded = false } = await $.state.get(REMINDED)
152 if (reminded) await $.state.set(REMINDED, false)
153 const judged = e.reason === 'answer' ? await settle($, opts, e.turnId, e.answer) : false
154 const { value: last = null } = await $.state.get(LAST)
155 if (last !== null && !bandStays(last.kind, { at: 'turn-end', reminded, judged })) await $.state.set(LAST, null)
156 } catch (error) {
157 debug($, 'turn.complete', error)
158 }
159 return r
160 })
161
162 on('prompt.submit', async ($, e, next) => {
163 if (!NUDGE_ORIGINS.includes(e.origin.kind)) return next(e)
164 if (!(await isActive($, opts))) {
165 try {
166 await clearStale($)
167 } catch (error) {
168 debug($, 'prompt.submit', error)
169 }
170 return next(e)
171 }
172 // Taken and cleared in one step, so two prompts never both carry it.
173 const taken: { nudge: string | null } = { nudge: null }
174 try {
175 await update($, PENDING_NUDGE, (pending = null) => {
176 taken.nudge = pending
177 return null
178 })
179 } catch (error) {
180 debug($, 'prompt.submit', error)
181 }
182 // Marked before the prompt goes on: its turn may end before `next` resolves.
183 if (taken.nudge !== null) await markReminded($, true)
184 const r = await next(taken.nudge === null ? e : { ...e, context: [...(e.context ?? []), taken.nudge] })
185 if (taken.nudge !== null && r.drop !== undefined) await markReminded($, false)
186 try {
187 const { value: last = null } = await $.state.get(LAST)
188 if (last !== null && !bandStays(last.kind, { at: 'prompt' })) await $.state.set(LAST, null)
189 } catch (error) {
190 debug($, 'prompt.submit', error)
191 }
192 return r
193 }).catch(($, e, next) => next(e))
194
195 // Review 7: a new conversation (/clear, or the session's end) starts clean.
196 on('session.end', async ($, e, next) => {
197 const r = await next(e)
198 if (!opts.enabled) return r
199 try {
200 await resetConversation($)
201 } catch (error) {
202 debug($, 'session.end', error)
203 }
204 return r
205 })
206
207 on('command.run', { command: 'receipts' }, ($, e) => runCommand($, opts, e.args))
208
209 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => (opts.enabled ? renderBand($, e, next) : next(e)))
210
211 on('ui.render', { component: 'Pane', requestId: 'receipts' }, ($, e, next) => (opts.enabled ? renderPane($, e, next) : next(e)))
212
213 // The band's [Details] shows only while the pane is shut: closing it (the
214 // person's close mark or key) draws the band again.
215 on('ui.close', { id: 'receipts' }, async ($, e, next) => {
216 const r = await next(e)
217 $.ui.invalidate('ui.render')
218 return r
219 }).catch(($, e, next) => next(e))
220}
221
222type Render = Parameters<Hook<'ui.render'>>
223
224// `/receipts` opens the pane; its subcommands write the plugin's settings.
225async function runCommand($: Engine, opts: Options, args: string): Promise<{ text: string }> {
226 const plan = parseCommand(args)
227 if (plan.kind === 'usage') return { text: USAGE }
228 if (plan.kind === 'checks') {
229 const edit = editChecks(opts.checks, plan)
230 if (edit.value === undefined) return { text: edit.text }
231 const written = await $.config.set({ key: 'receipts.checks', value: edit.value })
232 return 'deny' in written && written.deny !== undefined ? { text: `Receipts could not change its checks: ${written.deny}` } : { text: edit.text }
233 }
234 if (plan.kind === 'open') {
235 // The command's own answer says when the pane waits undrawn, and why.
236 return { text: (await openPane($)) ?? 'Receipts pane opened.' }
237 }
238 const result = await $.config.set({ key: plan.key, value: plan.value })
239 return 'deny' in result && result.deny !== undefined ? { text: `Receipts could not change ${plan.key}: ${result.deny}` } : { text: plan.done }
240}
241
242// The band, for as long as bandStays (core/visibility.ts) keeps it, or [Dismiss].
243// It shares the space above the prompt: Receipts' row goes first and whatever
244// is beneath (another mod's band, the engine's own) is drawn under it, so
245// neither hides the other. With room for one row only, Receipts' row stands.
246async function renderBand($: Engine, e: Render[1], next: Render[2]) {
247 if (e.component !== 'AbovePrompt' || e.props.hasSurvey) return next(e)
248 const { value: last = null } = await $.state.get(LAST)
249 const { value: hidden = false } = await $.state.get(BAND_HIDDEN)
250 if (last === null || hidden) return next(e)
251 const elements = $.ui.resolve(e)
252 const mine = bandView(
253 elements,
254 last,
255 (await paneIsUp($)) ? null : () => void openDetails($),
256 () => void $.state.set(BAND_HIDDEN, true),
257 )
258 if (e.props.maxRows < 2) return mine
259 const { Box } = elements
260 const below = await next(e)
261 return (
262 <Box key="above-prompt" flexDirection="column">
263 {mine}
264 {below}
265 </Box>
266 )
267}
268
269// Opens the pane, then draws the band again without its [Details]. Answers
270// what to tell the person when the pane waits undrawn (waitingNote), else null.
271async function openPane($: Engine): Promise<string | null> {
272 const opened = await $.ui.open({ id: PANE.id, title: PANE.title })
273 $.ui.invalidate('ui.render')
274 return waitingNote(PANE.title, opened)
275}
276
277// The band's [Details]: a pane that waits undrawn says why, once, in a toast.
278async function openDetails($: Engine): Promise<void> {
279 const note = await openPane($)
280 if (note !== null) $.ui.toast(note)
281}
282
283// Whether the pane is open and drawn, by the engine's record: one waiting
284// undrawn (a narrow terminal) still needs the band's [Details]. A tab behind
285// another pane counts as up: no event says when tabs switch.
286async function paneIsUp($: Engine): Promise<boolean> {
287 try {
288 return (await $.ui.panes()).some(pane => pane.id === PANE.id && pane.isPlaced)
289 } catch (error) {
290 debug($, 'panes', error)
291 return false
292 }
293}
294
295async function renderPane($: Engine, e: Render[1], next: Render[2]) {
296 if (e.component !== 'Pane' || e.requestId !== PANE.id) return next(e)
297 const { value: history = [] } = await $.state.get(HISTORY)
298 return paneView($.ui.resolve(e), history, await $.clock.now())
299}
300
301async function isActive($: Engine, opts: Options): Promise<boolean> {
302 if (!opts.enabled) return false
303 const { value: inactive = false } = await $.state.get(INACTIVE)
304 return !inactive
305}
306
307// Records a finished tool call in the ledger: an edit that landed, or a shell run.
308async function record($: Engine, e: { tool: string; agentId?: string } & Record<string, unknown>, r: Record<string, unknown>, checks: readonly DeclaredCheck[]): Promise<void> {
309 if ('deny' in r && r.deny !== undefined) return
310 const at = await $.clock.now()
311 if (EDIT_TOOLS.includes(e.tool)) {
312 const result = r.result as { staged?: boolean } | undefined
313 if (r.isError === true || result?.staged === true) return
314 const path = await realPathOf($, String(e.file_path ?? e.notebook_path ?? ''))
315 // Only code reopens verification: docs, Claude's files, scratch and build output don't.
316 if (!isVerifiableEdit(path, await projectDir($))) return
317 await update($, LEDGER, (ledger = emptyLedger()) => recordEdit(ledger, path, at, e.agentId))
318 return
319 }
320 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return
321 const result = r.result as { backgroundTaskId?: string } | undefined
322 const evidence = classify(
323 {
324 tool: e.tool,
325 command: String(e.command ?? ''),
326 isError: r.isError === true,
327 text: typeof r.text === 'string' ? r.text : '',
328 backgrounded: typeof result?.backgroundTaskId === 'string' || e.run_in_background === true,
329 },
330 checks,
331 )
332 await update($, LEDGER, (ledger = emptyLedger()) => recordRun(ledger, evidence, at, e.agentId))
333}
334
335// Judges an answer once: classic.Stop and turn.complete share the verdict for
336// the same text, and an answer rewritten after a send-back is judged afresh.
337async function judgeOnce($: Engine, opts: Options, turnId: string, startSeq: number, answer: string): Promise<Verdict | null> {
338 const { value: ledger = emptyLedger() } = await $.state.get(LEDGER)
339 // The same answer after another run (a rerun after a send-back) is judged afresh.
340 const key = `${turnId}:${ledger.nextSeq}:${answer.length}:${answer.slice(0, 64)}`
341 const { value: judged = null } = await $.state.get(JUDGED)
342 if (judged !== null && judged.key === key) return judged.verdict
343 const claims = await confirmClaims($, opts, findClaims(answer, opts.checks))
344 const verdict = judge(claims, ledger, startSeq)
345 await $.state.set(JUDGED, { key, verdict })
346 return verdict
347}
348
349// The turn ended with an answer: keep its verdict for the band and the pane,
350// and queue the nudge. The stamp was written at classic.Stop. Answers whether
351// the turn gave a verdict.
352async function settle($: Engine, opts: Options, turnId: string, answer: string): Promise<boolean> {
353 const turn = (await $.state.get(CURRENT_TURN)).value ?? null
354 const startSeq = turn !== null && turn.turnId === turnId ? turn.startSeq : 0
355 const verdict = await judgeOnce($, opts, turnId, startSeq, answer)
356 if (verdict === null) return false
357 const shown = await shownFor($, opts, verdict, startSeq)
358 const at = await $.clock.now()
359 // A newer verdict replaces the band's, shown or not.
360 await $.state.set(LAST, shown.band ? verdict : null)
361 await $.state.set(BAND_HIDDEN, false)
362 await update($, HISTORY, (history = []) => [...history, { turnId, at, verdict }].slice(-HISTORY_LIMIT))
363 await $.state.set(PENDING_NUDGE, shown.nudge ? decide(verdict, opts.mode, true, true).nudge : null)
364 return true
365}
366
367// Whether the prompt now entering carried the reminder (a refused one did not).
368async function markReminded($: Engine, reminded: boolean): Promise<void> {
369 try {
370 await $.state.set(REMINDED, reminded)
371 } catch (error) {
372 debug($, 'prompt.submit', error)
373 }
374}
375
376// What this verdict shows (core/visibility.ts): carried over when no code edit
377// was made during the turn.
378async function shownFor($: Engine, opts: Options, verdict: Verdict, startSeq: number): Promise<Visibility> {
379 const { value: ledger = emptyLedger() } = await $.state.get(LEDGER)
380 return visibility(verdict, !editedSince(ledger, startSeq), opts.verbosity)
381}
382
383// Keeps every 'sure' claim. A 'maybe' claim is kept only with the smart check
384// on and a model answering YES; an unanswered question or a failure drops it.
385async function confirmClaims($: Engine, opts: Options, claims: Claim[]): Promise<Claim[]> {
386 const kept: Claim[] = []
387 for (const claim of claims) {
388 if (claim.certainty === 'sure') {
389 kept.push(claim)
390 continue
391 }
392 if (!opts.smartCheck) continue
393 try {
394 const reply = await $.model.complete({ model: SMART_MODEL, maxTokens: SMART_MAX_TOKENS, system: SMART_SYSTEM, prompt: claim.sentence })
395 if (reply.isAnswered && isYes(reply.text)) kept.push(claim)
396 } catch (error) {
397 debug($, 'smart check', error)
398 }
399 }
400 return kept
401}
402
403function isOlder(version: string, min: readonly number[]): boolean {
404 const parts = version.split(/[.-]/).map(Number)
405 for (let i = 0; i < min.length; i++) {
406 const part = parts[i] ?? 0
407 if (Number.isNaN(part)) return false
408 if (part !== min[i]) return part < min[i]
409 }
410 return false
411}
412
413// A path as the file system names it: Windows 8.3 short names expanded
414// (C:\Users\JOHNDO~1 is C:\Users\JohnDoe), links and `..` resolved, the
415// real case. Measured on Claude Code 2.1.296. The spelling as given
416// when it does not resolve.
417async function realPathOf($: Engine, path: string): Promise<string> {
418 if (path === '') return path
419 try {
420 return (await $.fs.stat(path, { resolve: true })).realPath ?? path
421 } catch {
422 return path
423 }
424}
425
426// The session's directory, resolved once per spelling (a /cd brings a new one).
427const REAL_DIRS = new Map<string, string>()
428
429async function projectDir($: Engine): Promise<string> {
430 const cwd = await $.session.cwd()
431 const known = REAL_DIRS.get(cwd)
432 if (known !== undefined) return known
433 const real = await realPathOf($, cwd)
434 REAL_DIRS.set(cwd, real)
435 return real
436}
437
438// Appends the stamp unless this very answer of this turn was stamped already
439// (a hook above that sends the answer back after us makes Stop run again).
440async function stampOnce($: Engine, key: string, text: string): Promise<void> {
441 const { value: stamped = null } = await $.state.get(STAMPED)
442 if (stamped === key) return
443 await $.state.set(STAMPED, key)
444 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
445}
446
447// Receipts was turned off: drop a verdict and a nudge left from before.
448async function clearStale($: Engine): Promise<void> {
449 const { value: nudge = null } = await $.state.get(PENDING_NUDGE)
450 if (nudge !== null) await $.state.set(PENDING_NUDGE, null)
451 const { value: reminded = false } = await $.state.get(REMINDED)
452 if (reminded) await $.state.set(REMINDED, false)
453 const { value: last = null } = await $.state.get(LAST)
454 if (last !== null) await $.state.set(LAST, null)
455}
456
457async function resetConversation($: Engine): Promise<void> {
458 await $.state.set(LEDGER, emptyLedger())
459 await $.state.set(CURRENT_TURN, null)
460 await $.state.set(JUDGED, null)
461 await $.state.set(SENT_BACK, [])
462 await $.state.set(LAST, null)
463 await $.state.set(PENDING_NUDGE, null)
464 await $.state.set(REMINDED, false)
465 await $.state.set(STAMPED, null)
466}
467
468// A short, stable fingerprint of a text (FNV-1a), for dedupe keys.
469function hashText(text: string): string {
470 let hash = 0x811c9dc5
471 for (let i = 0; i < text.length; i++) {
472 hash ^= text.charCodeAt(i)
473 hash = Math.imul(hash, 0x01000193) >>> 0
474 }
475 return hash.toString(36)
476}
477
478function debug($: Engine, where: string, error: unknown): void {
479 $.ui.log(`receipts: ${where} failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
480}
481hooks/core/checks.ts 35 lines1// The `checks` setting: commands the person declares as checks, each written
2// `tests: <command>` or `build: <command>`. Pure.
3
4import type { DeclaredCheck } from './types.ts'
5
6const ENTRY = /^(tests?|build)\s*:\s*(\S.*)$/i
7
8// Reads the setting as stored: a list of entries (`/config`, the command), or
9// one text with entries split by `;` or new lines. An entry in neither form
10// is returned in `invalid`, so it can be named rather than silently ignored.
11export function parseChecks(value: unknown): { checks: DeclaredCheck[]; invalid: string[] } {
12 const items = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
13 const checks: DeclaredCheck[] = []
14 const invalid: string[] = []
15 for (const item of items) {
16 if (typeof item !== 'string') continue
17 for (const part of item.split(/[;\n]/)) {
18 const entry = part.trim()
19 if (entry === '') continue
20 const match = ENTRY.exec(entry)
21 if (match === null) {
22 invalid.push(entry)
23 continue
24 }
25 checks.push({ kind: match[1].toLowerCase().startsWith('test') ? 'tests' : 'build', prefix: match[2].trim() })
26 }
27 }
28 return { checks, invalid }
29}
30
31// The entry as the setting stores it.
32export function checkEntry(check: DeclaredCheck): string {
33 return `${check.kind}: ${check.prefix}`
34}
35hooks/core/claims.ts 213 lines1// Finds success claims in an answer (spec §4, "Lexicon"). Pure and English-only.
2// Precision first: a sentence that hedges, disclaims, negates, asks or
3// conditions is not a claim, so Receipts stays quiet rather than nag.
4
5import { normalizeNewlines } from '../shared/ansi.ts'
6import { checkKindOf } from './evidence.ts'
7import type { Claim, ClaimKind, DeclaredCheck } from './types.ts'
8
9// Inline code stands in the prose as its index between these two private-use
10// characters (blankIgnored), so it never reads as a number of the text.
11const CODE_OPEN = '\uE000'
12const CODE_CLOSE = '\uE001'
13
14// Each pattern is a way of saying "the work is finished".
15const CLAIMS: readonly RegExp[] = [
16 /\bI(?:'ve|\s+have)\s+(?:now\s+|just\s+|also\s+|already\s+|successfully\s+)?(?:fixed|implemented|resolved|completed|added|updated|finished|verified|confirmed)\b/i,
17 /\bI\s+(?:fixed|resolved|implemented|completed|finished)\b/i,
18 /(?:\b(?:is|are)|'s|'re)\s+(?:now\s+)?(?:all\s+)?(?:fixed|done|working(?!\s+(?:on|through|towards?)\b)|passing|green|resolved|complete|completed|finished)\b/i,
19 /\b(?:tests?|specs?|suites?|checks?)\s+(?:now\s+|all\s+|still\s+)?(?:pass|passes|passed|passing|succeed|succeeds|succeeded)\b/i,
20 // "Tests: 673 pass", "**Tests:** 673 pass".
21 /\btests?\b[*:\s]*\d+\s+(?:pass|passes|passed|passing)\b/i,
22 // "every TypeScript test and the site build passed", "All the unit tests in X pass".
23 /\b(?:every|all)\b[^.;]{0,60}?\b(?:tests?|checks?|builds?)\b[^.;]{0,60}?\b(?:pass|passes|passed|succeeded)\b/i,
24 /\b(?:builds?|compiles?)\s+(?:now\s+)?(?:cleanly|successfully|succeeds?|passes|fine)\b/i,
25 /\b(?:builds?|build\s+checks?|lint|typecheck|type-check|ci)\s+(?:all\s+|still\s+|now\s+)?(?:passed|passes|succeeded)\b/i,
26 /\b(?:lint|typecheck|type-check|types|build|tsc|eslint|ruff)\b[^.;]{0,30}?\bclean\b/i,
27 /\bno\s+(?:more\s+)?(?:type\s+|lint\s+|compile\s+|compiler\s+|build\s+|typescript\s+|page\s+)?errors\b/i,
28 /\b(?:verified|confirmed)\s+(?:that\s+)?(?:it|this|everything|the\s+\w+)\s+works?\b/i,
29 /^(?:verified|confirmed)(?:\s+so\s+far)?\s*:/i,
30 // "It now passes", but not "traffic that passes through Cloudflare".
31 /\b(?:it|this|that|everything)\s+(?:now\s+)?(?:works|passes)\b(?!\s+(?:through|to|the|a|an|on|in|into|over|by|for|from|with|along|via)\b)/i,
32 /\bready\s+(?:to|for)\s+(?:merge|ship|deploy|release)\b/i,
33 // An inline-code command as the subject: "`pnpm verify` passes (422 tests)".
34 /\uE000\d+\uE001\s+(?:now\s+|still\s+)?(?:passes|passed|succeeds|succeeded|is\s+green|is\s+clean)\b/,
35]
36
37// Whole-sentence closers ("Done.", "**Fixed!**", "All set ✅").
38const TERSE = new Set(['done', 'fixed', 'all set', 'all done', 'all good', 'all fixed', 'complete', 'completed', 'finished', 'resolved'])
39// A closer that opens a longer sentence: "Fixed, if you want…", "Fixed and live (…)".
40const TERSE_LEAD = /^(?:done|fixed|all\s+set|all\s+done|all\s+fixed|finished|resolved)(?=\s*[,;:]|\s+and\s|\s+[—–-]\s)/i
41// An instruction, not a report: "Confirm the new tests pass, then…".
42const IMPERATIVE = /^(?:confirm|make\s+sure|ensure|check|verify|run|try|let's|let\s+me)\b/i
43
44const HEDGE = /\b(?:should|probably|likely|might|may|maybe|perhaps|hopefully|presumably|i\s+think|i\s+believe|i\s+guess|seems?|appears?|ought\s+to|in\s+theory)\b/i
45const DISCLAIMER =
46 /\b(?:haven't|have\s+not|hasn't|didn't|did\s+not|couldn't|could\s+not|can't|cannot|wasn't\s+able\s+to|unable\s+to)\s+(?:yet\s+)?(?:run|test|tested|verify|verified|check|checked|execute|confirm|confirmed)\b|\buntested\b|\bunverified\b|\bnot\s+(?:yet\s+)?(?:been\s+)?(?:tested|verified|run)\b|\byou(?:'ll|\s+will)?\s+(?:need|want|have)\s+to\s+(?:run|test|verify)\b|\bplease\s+(?:run|test|verify)\b/i
47const NEGATION =
48 /(?:\bnot|\bnever|n't)\s+(?:(?:yet|been|be|fully|quite|all|actually|really)\s+)*(?:fixed|done|working|passing|passed|green|resolved|complete|completed|pass|work|works|succeed|succeeded)\b|\bstill\s+(?:fails?|failing|broken|red|errors?|erroring)\b|\bno\s+longer\s+(?:pass|passes|works?)\b|\bnothing\s+(?:\w+\s+)?(?:is|was|has\s+been)\b|\bnot\s+(?:\w+\s+){0,2}yet\b/i
49const FUTURE = /\bwill\s+(?:now\s+)?(?:be|pass|work|succeed)\b|\bgoing\s+to\b|\bI(?:'ll|\s+will)\b|\bnext\b/i
50// A condition, or past context, only counts when it comes before the claim
51// ("if the tests pass", "before my change, all tests passed"), not after it
52// ("Fixed, if you want to rerun it").
53const CONDITION =
54 /\b(?:if|once|when|whenever|while|unless|until|whether|assuming|as\s+soon\s+as)\b|\bbefore\s+(?:my|the|this|your|our)\s+(?:change|changes|edit|edits|fix|refactor|patch)\b|\bafter\s+(?:you|we)\b/i
55// A success phrase someone did not say, not a report of success: "My last
56// message didn't say the tests passed", "I'm not claiming the build is fixed".
57// Only when the frame leads into the claim within one clause: "I never said
58// otherwise: all tests pass" and "I didn't break anything, all tests pass"
59// still claim.
60const DENIED_REPORT =
61 /(?:\bnot|\bnever|n't)\s+(?:\w+\s+){0,2}?(?:say|said|saying|claim|claimed|claiming|state|stated|stating|tell|told|telling|report|reported|reporting|mean|meant|suggest|suggested|imply|implied|promise|promised|mention|mentioned)\b(?=[^,;:—–]*$)/i
62// An explanation or an introduction, not a report: "Why it works on those two
63// sites:", "Here's how it works:", "This is why the build passes."
64const EXPLANATION = /^(?:why|how|what|here(?:'s|\s+is)\s+(?:why|how|what)|this\s+is\s+(?:why|how)|that's\s+(?:why|how)|the\s+reason)\b/i
65// An instruction after a leading phrase: "Before the push, check that … passed".
66const LEADING_PHRASE = /^[^,;:]{1,40},\s*/
67// A TDD step, not a success: "I've added a failing test that reproduces the bug."
68const FAILING_TEST_ADDED = /\b(?:added|wrote|written|writing|created|adding)\s+(?:a|an|the|two|three|\d+)?\s*(?:new\s+)?(?:failing|red)\s+(?:unit\s+|regression\s+)?tests?\b/i
69// The answer itself says something is failing: Claude already told the person,
70// so there is nothing to catch ("3 tests are failing; I left them for you",
71// "the integration suite fails because Docker isn't running", "1 passed and 4 failed").
72// A sentence about fixing a past failure ("fixed the failing test") is no disclosure.
73const DISCLOSES_FAILURE: readonly RegExp[] = [
74 /\b(?:is|are|still|keeps?)\s+(?:\w+\s+){0,2}?(?:failing|broken|red)\b/i,
75 /\b(?:tests?|suites?|specs?|builds?|e2e|checks?|lint|typecheck)\s+(?:\w+\s+){0,3}?(?:fails|fail|failing)\b(?!\s+(?:before|without))/i,
76 /\b[1-9]\d*\s+(?:\w+\s+)?(?:tests?\s+)?(?:failed|failing|fail)\b/i,
77]
78const PAST_FAILURE = /\b(?:fix(?:ed|es|ing)?|resolv\w*|repair\w*|previously|used\s+to|was|were|before|without|reproduc\w*)\b/i
79
80// A claim about tests names them as a result: tests/specs/a suite with a pass
81// or fail verb, "N tests", or "Tests: N". The verb ("I opened it to test")
82// is not enough.
83const TESTS_KIND =
84 /\b(?:tests?|specs?|suites?)\b[^.;]{0,40}?\b(?:pass|passes|passed|passing|green|succeed\w*|fail\w*)\b|\b(?:pass|passes|passed|passing)\b[^.;]{0,20}?\b(?:tests?|specs?)\b|\b\d+\s+(?:[\w-]+\s+){0,2}(?:tests?|specs?)\b|\btests?:[*\s]*\d+/i
85// A claim about the build, types or lint, judged by build runs, not by tests.
86const BUILD_KIND = /\b(?:builds?|compiles?|compiled|typechecks?|type-checks?|type\s+check|types|tsc|lint|linter|linting|eslint|ruff|clippy)\b/i
87// Inline code as the claim's subject ("`npm test` now passes", "`tsc` is
88// clean"): the code's placeholder (blankIgnored) right before the success verb.
89const CODE_SUBJECT = /\uE000(\d+)\uE001[*_)]*\s+(?:now\s+|still\s+|also\s+)?(?:passes|passed|succeeds|succeeded|is\s+green|is\s+clean)\b/
90const CONJUNCTION = /\b(?:but|although|except)\b/i
91const MAYBE_WORDS = 25
92
93// `checks`: the person's declared checks, so "`pnpm verify` passes" takes their kind.
94export function findClaims(text: string, checks: readonly DeclaredCheck[] = []): Claim[] {
95 const { prose, codes } = blankIgnored(text)
96 const all = sentences(prose)
97 if (all.some(disclosesFailure)) return []
98 const claims: Claim[] = []
99 for (const sentence of all) {
100 const claim = asClaim(sentence, codes, checks)
101 if (claim !== null) claims.push(claim)
102 }
103 return claims
104}
105
106function disclosesFailure(sentence: string): boolean {
107 return DISCLOSES_FAILURE.some(pattern => pattern.test(sentence)) && !PAST_FAILURE.test(sentence)
108}
109
110function asClaim(sentence: string, codes: readonly string[], checks: readonly DeclaredCheck[]): Claim | null {
111 const lead = sentence.replace(/^[\s*_✓✔✅]+/u, '')
112 if (IMPERATIVE.test(lead) || IMPERATIVE.test(lead.replace(LEADING_PHRASE, ''))) return null
113 if (EXPLANATION.test(lead) || FAILING_TEST_ADDED.test(sentence)) return null
114 // A line that introduces what follows ("Here is what changed:") reports nothing.
115 if (/:\s*$/.test(sentence)) return null
116 const at = TERSE_LEAD.test(lead) ? 0 : claimIndex(sentence)
117 if (at < 0) return null
118 if (sentence.endsWith('?')) return null
119 if (HEDGE.test(sentence) || DISCLAIMER.test(sentence) || NEGATION.test(sentence) || FUTURE.test(sentence)) return null
120 if (CONDITION.test(sentence.slice(0, at)) || DENIED_REPORT.test(sentence.slice(0, at))) return null
121 const words = sentence.split(/\s+/).filter(Boolean).length
122 return {
123 sentence: sentence.replace(/\uE000(\d+)\uE001/g, (_, i: string) => `\`${codes[Number(i)]}\``),
124 kind: TESTS_KIND.test(sentence) ? 'tests' : BUILD_KIND.test(sentence) ? 'build' : codeSubjectKind(sentence, codes, checks),
125 certainty: words > MAYBE_WORDS && CONJUNCTION.test(sentence) ? 'maybe' : 'sure',
126 }
127}
128
129// The kind a claim takes from inline code as its subject: the command's own
130// (core/evidence.ts checkKindOf), else generic.
131function codeSubjectKind(sentence: string, codes: readonly string[], checks: readonly DeclaredCheck[]): ClaimKind {
132 const index = CODE_SUBJECT.exec(sentence)?.[1]
133 const code = index === undefined ? undefined : codes[Number(index)]
134 return (code !== undefined ? checkKindOf(code, checks) : null) ?? 'generic'
135}
136
137// Where the claim starts in the sentence, or -1 when there is none.
138function claimIndex(sentence: string): number {
139 if (isTerse(sentence)) return 0
140 let first = -1
141 for (const pattern of CLAIMS) {
142 const match = pattern.exec(sentence)
143 if (match === null) continue
144 // A label, not a report: "The free path that works: …". ("All tests pass: 625
145 // TypeScript, 67 Rust" still claims; only a relative clause before the colon labels.)
146 if (/^(?:that|which)\b/i.test(match[0]) && /^[*_]*\s*:/.test(sentence.slice(match.index + match[0].length))) continue
147 if (first < 0 || match.index < first) first = match.index
148 }
149 return first
150}
151
152function isTerse(sentence: string): boolean {
153 const hadCheck = /✅|✔/u.test(sentence)
154 const core = sentence
155 .replace(/[*_#]/g, '')
156 .replace(/✅|✔️?|☑️?|🎉/gu, '')
157 .replace(/[\s.!:]+$/, '')
158 .trim()
159 .toLowerCase()
160 return TERSE.has(core) || (core === '' && hadCheck)
161}
162
163// Blanks what is not the answer's own prose: code blocks, quotes, unchecked
164// to-dos, headings. Inline code becomes a placeholder, so "`pnpm verify` passes" keeps
165// its subject while the code's own words never count.
166function blankIgnored(text: string): { prose: string; codes: string[] } {
167 const lines = normalizeNewlines(text).replace(/[‘’]/g, "'").split('\n')
168 const codes: string[] = []
169 const kept: string[] = []
170 let fence: string | null = null
171 for (const line of lines) {
172 const opener = /^\s*(`{3,}|~{3,})/.exec(line)
173 if (fence !== null) {
174 if (opener !== null && opener[1][0] === fence[0] && opener[1].length >= fence.length) fence = null
175 kept.push('')
176 continue
177 }
178 if (opener !== null) {
179 fence = opener[1]
180 kept.push('')
181 continue
182 }
183 if (/^\s*>/.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\[\s\]/.test(line) || isHeading(line)) {
184 kept.push('')
185 continue
186 }
187 const unquoted = line.replace(/`([^`]*)`/g, (_, code: string) => `${CODE_OPEN}${codes.push(code) - 1}${CODE_CLOSE}`)
188 kept.push(unquoted.replace(/"[^"]*"|“[^”]*”/g, ' '))
189 }
190 return { prose: kept.join('\n'), codes }
191}
192
193// A heading names a section, it reports nothing: "## Results", or a whole line
194// in bold with no sentence end ("**What's working for you**"). A bold closer
195// ("**Done**", "**Fixed!**") is still one.
196function isHeading(line: string): boolean {
197 if (/^\s*#{1,6}\s/.test(line)) return true
198 const bold = /^\s*(?:\*\*|__)([^*_]+)(?:\*\*|__)\s*:?\s*$/.exec(line)
199 return bold !== null && !/[.!?]\s*$/.test(bold[1]) && !isTerse(bold[1])
200}
201
202// Splits after . ! ? (and any closing emphasis or quote: "passes.**") and at newlines.
203function sentences(text: string): string[] {
204 const found: string[] = []
205 for (const line of text.split('\n')) {
206 for (const piece of line.split(/(?<=[.!?][*_)"'”’]*)\s+/)) {
207 const sentence = piece.replace(/^\s*(?:[-*+]\s+(?:\[[xX]\]\s+)?|\d+[.)]\s+|#+\s+)/, '').trim()
208 if (sentence !== '') found.push(sentence)
209 }
210 }
211 return found
212}
213hooks/core/copy.ts 30 lines1// Every string Receipts shows the user or hands Claude. Exact copy: tests pin it.
2
3import { CLAIM_CHARS, truncate } from './text.ts'
4import type { Verdict } from './types.ts'
5
6export function bandText(v: Verdict): string {
7 if (v.kind === 'verified') return `✅ Verified · ${v.evidence?.summary ?? ''}`
8 if (v.kind === 'unverified') return '⚠️ Unverified: no receipts · no check ran after the last edit'
9 return `❌ Contradicted: "${claimOf(v)}" · ${v.evidence?.summary ?? ''}`
10}
11
12export function stampText(v: Verdict): string {
13 return `🧾 ${bandText(v)}`
14}
15
16export function sendBackText(v: Verdict): string {
17 if (v.kind === 'contradicted') {
18 return `Receipts: you claimed "${claimOf(v)}", but ${v.evidence?.summary ?? 'a check failed'}. Fix it and verify before claiming success.`
19 }
20 return `Receipts: you claimed "${claimOf(v)}", but no check ran after your last edit. Run the relevant tests or build before claiming success.`
21}
22
23export function nudgeText(v: Verdict): string {
24 return `Receipts: your previous claim '${claimOf(v)}' was not verified by any check run after your last edit. Verify before claiming success.`
25}
26
27function claimOf(v: Verdict): string {
28 return truncate(v.claim.sentence, CLAIM_CHARS)
29}
30hooks/core/edits.ts 44 lines1// Which edits reopen verification (spec: "only code edits"). Docs, Claude's
2// own files, memory, scratch, build output and anything outside the project
3// change nothing a test could check, so they never make a claim "unverified".
4
5const PROSE = /\.(?:md|mdx|markdown|txt|rst|adoc)$/
6// Path segments whose files are Claude's, scratch, or generated.
7const NOT_CODE_DIRS = new Set(['.claude', '.agent', 'scratch', 'target', 'dist', 'build', 'node_modules', '.cache', '__pycache__'])
8const TEMP_ROOTS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^[a-z]:\/(?:users\/[^/]+\/appdata\/local\/temp|windows\/temp)\//, /^[a-z]:\/tmp\//]
9
10export function isVerifiableEdit(path: string, cwd: string): boolean {
11 if (path.trim() === '') return false
12 const root = normalize(cwd)
13 const file = normalize(isAbsolute(path) ? path : `${cwd}/${path}`)
14 if (PROSE.test(file)) return false
15 // Outside the project is never verifiable; with no project known, temp dirs aren't either.
16 // (A project that itself lives under a temp dir keeps its own files.)
17 if (root !== '' && !file.startsWith(`${root}/`)) return false
18 if (root === '' && TEMP_ROOTS.some(pattern => pattern.test(file))) return false
19 const inside = root !== '' ? file.slice(root.length + 1) : file
20 return !inside.split('/').some(segment => NOT_CODE_DIRS.has(segment))
21}
22
23function isAbsolute(path: string): boolean {
24 return /^(?:[a-zA-Z]:[\\/]|[\\/]|~[\\/])/.test(path)
25}
26
27// Forward slashes, no `.` or `..` segments, Git Bash's /c/ read as c:/, and
28// lower case: Windows paths compare case-insensitively, and on the other
29// systems a case clash in a project's own paths is rare enough to ignore.
30function normalize(path: string): string {
31 let p = path.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/')
32 p = p.replace(/^\/([a-zA-Z])\//, '$1:/')
33 const parts: string[] = []
34 for (const part of p.split('/')) {
35 if (part === '.' || (part === '' && parts.length > 0)) continue
36 if (part === '..') {
37 if (parts.length > 1) parts.pop()
38 continue
39 }
40 parts.push(part)
41 }
42 return parts.join('/').replace(/\/$/, '').toLowerCase()
43}
44hooks/core/evidence.ts 488 lines1// Classifies a Bash or PowerShell run as evidence (spec §4, "Evidence classes").
2// The engine reports a non-zero exit as isError with text starting "Exit code N".
3// In Bash a pipe or a later `;` statement hides that code; in PowerShell a
4// cmdlet pipe keeps it and only a later native command replaces it. Where it
5// is hidden, an echoed status (`echo exit=$?`) or the output decides.
6
7import { normalizeNewlines, stripAnsi } from '../shared/ansi.ts'
8import { COMMAND_CHARS, truncate } from './text.ts'
9import type { DeclaredCheck, Evidence, RunInput, Strength } from './types.ts'
10
11const OUTPUT_CHARS = 65_536
12
13const STRONG: readonly RegExp[] = [
14 /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+|run-script\s+)?(?:test|t)(?:[:-][\w:.-]+)?(?:\s|$)/,
15 /^(?:vitest|jest|mocha|ava|karma|playwright\s+test|cypress\s+run)\b/,
16 /^(?:node|deno|bun)\s+(?:--test|test)\b/,
17 /^(?:pytest|py\.test|tox|nox)\b/,
18 /^(?:python[\d.]*|py)\s+(?:-\w+\s+)*-m\s+(?:pytest|unittest|tox|nox)\b/,
19 /^cargo\s+(?:test|nextest)\b/,
20 /^go\s+test\b/,
21 /^dotnet\s+test\b/,
22 /^gradlew?\b.*\s(?::?[\w-]+:)*(?:test|check)\b/,
23 /^mvnw?\b.*\s(?:test|verify)\b/,
24 /^(?:invoke-pester|[\w-]*pester)\b/,
25 /^(?:rspec|phpunit|ctest)\b/,
26 /^(?:make|mix|swift)\s+(?:test|check)\b/,
27 /^(?:php\s+)?artisan\s+test\b/,
28]
29
30const MEDIUM: readonly RegExp[] = [
31 /^(?:tsc|vue-tsc)(?:\s|$)/,
32 /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck|type-check|lint|check|tsc|compile)(?:[:-][\w:.-]+)?(?:\s|$)/,
33 /^cargo\s+(?:build|check|clippy)\b/,
34 /^go\s+(?:build|vet)\b/,
35 /^dotnet\s+build\b/,
36 /^(?:mypy|pyright|basedpyright|ruff|eslint|flake8|pylint)\b/,
37 /^biome\s+(?:check|lint|ci)\b/,
38 /^(?:next|vite|webpack|turbo|nx|astro)\b.*\bbuild\b/,
39 /^gradlew?\b.*\s(?:assemble|build)\b/,
40 /^mvnw?\b.*\s(?:package|install|compile)\b/,
41 /^(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b.*(?:\blocalhost\b|\b127\.0\.0\.1\b|\[::1\]|\b0\.0\.0\.0\b)/,
42]
43
44// Flags that make a runner list, describe or build without running a check.
45// Formatting checks neither back nor contradict a claim (lead's rule): code can
46// be correct and unformatted, or formatted and broken.
47const FORMAT_ONLY = /^(?:ruff\s+format|prettier|black|cargo\s+fmt|rustfmt|gofmt|dotnet\s+format|biome\s+format)\b/
48
49// A build, type or lint tool's own all-clear line, which a failing run never
50// prints. When a later step owns the exit code, it is the only pass we trust.
51const TOOL_PASS_LINES: Readonly<Record<string, RegExp>> = {
52 ruff: /^All checks passed!\s*$/m,
53 pyright: /^\s*0 errors?, \d+ warnings?/m,
54 basedpyright: /^\s*0 errors?, \d+ warnings?/m,
55 mypy: /^Success: no issues found/m,
56 cargo: /^\s*Finished\s+`?\w+`?\s+profile/m,
57 dotnet: /^\s*Build succeeded\./m,
58 gradle: /^BUILD SUCCESSFUL\b/m,
59 gradlew: /^BUILD SUCCESSFUL\b/m,
60 mvn: /\bBUILD SUCCESS\b/m,
61 mvnw: /\bBUILD SUCCESS\b/m,
62}
63
64// `--select ALL` turns on every lint rule to explore, not to gate (lower-cased here).
65const NOT_A_RUN = /(?:^|\s)--(?:version|help|collect-only|co|no-run|listtests|list-tests|list|dry-run|init|showconfig)\b|(?:^|\s)--select[ =]all\b/
66
67// A fallback that swallows the result: `|| true`, `|| exit 0`, `|| :`.
68const SWALLOW = /^(?:true|:|\$true|exit(?:\s+\/b)?\s+0|exit)$/i
69
70// PowerShell statements that run no native command, so leave $LASTEXITCODE be:
71// cmdlets (Verb-Noun), common aliases and keywords, variables, strings, blocks.
72const PS_NOT_NATIVE = /^(?:[$"'@[({}#%?.]|[a-z]+-[a-z]+\b|(?:cd|ls|dir|echo|cat|type|cp|copy|mv|move|rm|del|ri|ni|mkdir|pwd|sleep|select|where|sort|foreach|if|else|elseif|for|while|try|catch|finally|return|exit|write|sl|gci|gc|sc|iwr|irm|cls|clear|param|function)\b)/i
73
74// A test runner's own failure lines, and a build's or type check's.
75const TEST_FAIL_MARKERS: readonly RegExp[] = [
76 /test result: FAILED/,
77 /--- FAIL:/,
78 /^\s*FAIL\b/m,
79 /^FAILED\s/m,
80 /\bFailed!/,
81 /\bTests run:.*\bFailures: [1-9]/,
82 /^\s*[✗✕×✖]\s/m,
83]
84const BUILD_FAIL_MARKERS: readonly RegExp[] = [
85 /\berror TS\d+/,
86 /\bBUILD FAILED\b|\bBUILD FAILURE\b|FAILURE: Build failed/,
87 /^error(?:\[E\d+\])?:/m,
88 /\bcould not compile\b/,
89]
90const ZERO_MARKERS =
91 /\bno tests? (?:ran|found|to run|were found|collected|executed)\b|\bcollected 0 items\b|\bno test files? found\b|\brunning 0 tests\b|\bTotal tests: 0\b|\bTests:\s+0 total\b|\bRan 0 tests\b|\bTests Passed: 0, Failed: 0\b|\bTests\s+no tests\b/i
92// The shell failed before any runner spoke: a PowerShell error record, a missing command.
93const SHELL_FAILURE = /^\s*Line \|$|^(?:\w+\.)*\w*Exception:|: command not found\b|is not recognized as (?:(?:the|a) name of a cmdlet|an internal or external command)/m
94
95// "no tests" because the test file itself broke (an import that failed) is a failure.
96const ZERO_BUT_BROKEN = /^\s*(?:\w*Error|Error):/m
97
98// Counts bound to their own word: "3 failed" and "failed: 3", never the "268"
99// of "passed 268 failed 0". Within one line: " 0 fail\n 30 expect() calls"
100// holds no "fail 30".
101const FAIL_COUNTS = [
102 /(?<!\b(?:passed|pass|ok)[:=]?[ \t]+)\b(\d+)[ \t]+(?:failed|failing|failures?)\b/gi,
103 /\b(?:failed|fail|failures?|failing)[:=]?[ \t]+(\d+)\b/gi,
104]
105const PASS_COUNTS = [
106 /(?<!\b(?:failed|fail)[:=]?[ \t]+)\b(\d+)[ \t]+(?:passed|passing)\b/gi,
107 /\b(?:passed|pass)[:=]?[ \t]+(\d+)\b/gi,
108]
109const ERROR_COUNTS = [/\b(\d+)[ \t]+errors?\b/gi]
110// A test summary line: where a runner run twice in one command last reported.
111const SUMMARY_LINE = /\b\d+[ \t]+(?:passed|failed|failing|passing)\b|\b(?:passed|failed):[ \t]*\d+/i
112// Steps before a runner that cannot stop it: cd, set, export, echo, source.
113const TRIVIAL_STEP = /^(?:cd|pushd|popd|set|export|echo|source|\.|set-location|sl)(?:\s|$)/i
114
115type Segment = { text: string; connector: string }
116
117// A test runner's own summary line, recognized by its shape whatever command
118// ran it (`pnpm verify`, `timeout 120 pnpm exec vitest`, a heredoc script).
119// Only these lines are read for a verdict: never words inside test names.
120const RUNNER_SUMMARIES: readonly RegExp[] = [
121 /^\s*Tests\s+(?:\d+\s+(?:passed|failed|skipped|todo)\b|no tests\b)/, // vitest
122 /^\s*Test Files\s+\d+\s+(?:passed|failed)\b/, // vitest
123 /^\s*Tests:\s+\d+\s+(?:passed|failed|skipped|todo|total)\b/, // jest
124 /^\s*\d+\s+(?:passed|failed|flaky)\s+\(\d+(?:\.\d+)?m?s\)/, // playwright
125 /^\s*\d+\s+(?:failed|flaky|interrupted|did not run)\s*$/, // playwright line reporter
126 /^=*\s*(?:\d+\s+(?:passed|failed|errors?|skipped|xfailed|xpassed|warnings?|deselected),?\s+)+in\s+[\d.]+s\b/, // pytest
127 /^test result: (?:ok|FAILED)\. \d+ passed/, // cargo test
128 /^(?:ok|FAIL)\s+\S+\s+(?:\(cached\)|[\d.]+s)/, // go test
129 /^[ℹ#] (?:pass|fail) \d+\s*$/, // node --test, TAP
130 /^\s*(?:Passed|Failed)!\s+-\s+Failed:\s+\d+/, // dotnet test
131 /Tests Passed: \d+, Failed: \d+/, // Pester
132 /^\s*\d+ (?:passing|failing)(?: \(\d|\s*$)/, // mocha
133 /^\s*\d+ (?:pass|fail)\s*$/, // bun test
134 /^Ran \d+ tests? in [\d.]+s\s*$/, // unittest
135]
136const SUMMARY_FAILS: readonly RegExp[] = [
137 /(?<!\b(?:passed|pass|ok)[:=]?[ \t]+)\b[1-9]\d*[ \t]+(?:failed|failing|fail|errors?|interrupted)\b/i,
138 /\b(?:failed|fail|failures?)[:=]?[ \t]+[1-9]\d*/i,
139 /^test result: FAILED/,
140 /^FAIL\s/,
141 /^\s*Failed!/,
142]
143// A run that covered only part of the suite: the runner says so ("114 filtered
144// out", "3 deselected"), or the command picks tests by name (`-k`, `-t`,
145// `--filter`, `-run`, `--grep`, `-g`, `--testNamePattern`, or cargo's `-- <name>`).
146const SUBSET_OUTPUT = /\b[1-9]\d*\s+(?:filtered out|deselected)\b/
147const SUBSET_COMMAND = /\s(?:-k|-t|-g|--filter|-run|--run|--grep|--testnamepattern|--test-name-pattern)(?:[ =]|$)|\s--\s+[a-z_]/
148// A runner's line for one failing file or test: jest and vitest, go.
149const RUNNER_FAIL_LINES: readonly RegExp[] = [/^\s*FAIL {1,2}\S+\.(?:test|spec)\.[cm]?[jt]sx?\b/m, /^\s*--- FAIL: \S+/m]
150// Commands that only show what an earlier run wrote: a summary they print is old news.
151const READER = /^(?:cat|type|less|more|head|tail|grep|rg|findstr|awk|sed|get-content|gc|select-string|sls|jq)\b/i
152// The run never finished: a timeout, an interrupt, or a call the person turned down.
153const NOT_FINISHED = /\bCommand timed out\b|\btimed out after\b|\bInterrupted by user\b|\[Request interrupted|\bdoesn't want to proceed with this tool use\b|\btool use was rejected\b/i
154
155// `checks`: commands the person declared as checks (core/checks.ts), read as
156// a test run (tests) or a build run (build) where nothing built in is stronger.
157export function classify(run: RunInput, checks: readonly DeclaredCheck[] = []): Evidence {
158 const segments = split(run.command)
159 // The last of the strongest segments decides: in `a; npm test; echo $?` that is the run whose status is shown.
160 let best: { index: number; strength: Strength } = { index: -1, strength: 'none' }
161 for (const [index, segment] of segments.entries()) {
162 const strength = strengthOf(segment.text, checks)
163 if (strength !== 'none' && rank(strength) >= rank(best.strength)) best = { index, strength }
164 }
165 const whole = normalizeNewlines(stripAnsi(run.text.length > OUTPUT_CHARS ? run.text.slice(-OUTPUT_CHARS) : run.text))
166 // A runner's own summary makes the run a test run, whatever its command was.
167 const steps = segments.filter(segment => !TRIVIAL_STEP.test(display(segment.text)))
168 const readsOnly = steps.length > 0 && steps.every(segment => READER.test(coreOf(segment.text)))
169 if (best.strength !== 'strong' && !readsOnly && runnerSummaries(whole).length > 0) {
170 const index = best.index >= 0 ? best.index : segments.indexOf(steps[0] ?? segments[0])
171 best = { index, strength: 'strong' }
172 }
173 const command = best.index < 0 ? display(run.command) : display(segments[best.index].text)
174 const shown = truncate(command, COMMAND_CHARS)
175 if (best.strength === 'none' || isSwallowed(segments, best.index)) {
176 return { strength: 'none', outcome: run.isError ? 'fail' : 'pass', command, summary: `${shown}: not a check` }
177 }
178 if (run.backgrounded) return { strength: best.strength, outcome: 'pending', command, summary: `${shown} is still running` }
179
180 // A runner run twice in one command (break the code, test, restore, test): its last summary decides.
181 const out = isRepeated(segments, best.index) ? fromLastSummary(whole) : whole
182 const summaries = runnerSummaries(out)
183 const failing = summaries.filter(line => SUMMARY_FAILS.some(pattern => pattern.test(line)))
184 const summaryPass = largest(summaries.join('\n'), PASS_COUNTS)
185 const summaryFail = largest(failing.join('\n'), FAIL_COUNTS)
186 const isZero = ZERO_MARKERS.test(out)
187 const echoed = echoedStatus(run, segments, best.index, out)
188 const exitCode = echoed ?? (run.isError ? (/^Exit code (\d+)/.exec(run.text.slice(0, 40))?.[1] ?? null) : '0')
189 // A failing run of part of the suite (a mutation check, one test by name) is
190 // marked, so it cannot overturn an earlier full pass (ledger.ts).
191 const isSubset = SUBSET_OUTPUT.test(out) || SUBSET_COMMAND.test(coreOf(segments[best.index].text))
192 const result = (outcome: Evidence['outcome'], summary: string, strength: Strength = best.strength): Evidence =>
193 outcome === 'fail' && isSubset && strength !== 'none' ? { strength, outcome, command, summary, subset: true } : { strength, outcome, command, summary }
194 const failed = (detail: string | null) =>
195 (exitCode !== null && exitCode !== '0' ? `${shown} exited ${exitCode}` : `${shown} failed`) + (detail !== null ? ` (${detail})` : '')
196 const passedText = summaryPass > 0 ? `${shown} passed (${summaryPass})` : `${shown} passed`
197 const zero = () => (ZERO_BUT_BROKEN.test(out) ? result('fail', failed('no tests ran')) : result('zero-tests', `${shown} ran no tests`))
198
199 // It never finished, or never started: no evidence either way.
200 if (run.isError && summaries.length === 0 && NOT_FINISHED.test(out)) return result('fail', `${shown}: did not finish`, 'none')
201 if (run.isError && summaries.length === 0 && !isZero && SHELL_FAILURE.test(out)) return result('fail', `${shown}: did not run`, 'none')
202
203 // The chain stopped before the runner: an earlier step failed and the runner said nothing.
204 const runnerSpoke = summaries.length > 0 || isZero || TEST_FAIL_MARKERS.some(pattern => pattern.test(out))
205 const before = stepsBefore(run.command, segments, best.index)
206 if (run.isError && best.strength === 'strong' && before.length > 0 && !runnerSpoke) {
207 const build = before.findLast(segment => strengthOf(segment.text, checks) !== 'none')
208 const errorCount = largest(out, ERROR_COUNTS)
209 const buildMarker = BUILD_FAIL_MARKERS.map(pattern => pattern.exec(out)?.[0].trim()).find(found => found !== undefined)
210 if (build === undefined || (errorCount === 0 && buildMarker === undefined)) return result('fail', `${shown}: did not run`, 'none')
211 const buildCommand = display(build.text)
212 const buildShown = truncate(buildCommand, COMMAND_CHARS)
213 const head = exitCode !== null && exitCode !== '0' ? `${buildShown} exited ${exitCode}` : `${buildShown} failed`
214 const detail = errorCount > 0 ? `${errorCount} error${errorCount === 1 ? '' : 's'}` : truncate(buildMarker ?? '', COMMAND_CHARS)
215 return { strength: strengthOf(build.text, checks), outcome: 'fail', command: buildCommand, summary: `${head} (${detail})` }
216 }
217
218 // A runner's own summary that reports failures is its verdict, whatever the
219 // exit code (Pester exits 0 on failures without -CI; a wrapper may swallow it).
220 if (failing.length > 0) return result('fail', failed(summaryFail > 0 ? `${summaryFail} failed` : truncate(failing[0].trim(), COMMAND_CHARS)))
221
222 // The exit code is the runner's (nothing after it can replace it, or it was
223 // echoed right after): it decides. Words in the output (a test named "returns
224 // 401 error", eslint warnings) never overturn it. Counts shown are cosmetic.
225 if (exitCode !== null && ownsExitCode(run, segments, best.index, echoed, exitCode)) {
226 if (exitCode === '0') {
227 if (isZero && summaryPass === 0) return zero()
228 const count = summaryPass > 0 ? summaryPass : largest(out, PASS_COUNTS)
229 return result('pass', count > 0 ? `${shown} passed (${count})` : `${shown} passed`)
230 }
231 if (isZero && summaryPass === 0) return zero()
232 const count = largest(out, FAIL_COUNTS)
233 return result('fail', failed(count > 0 ? `${count} failed` : null))
234 }
235
236 // Otherwise only the runner's own lines decide: its summary, a per-file
237 // failure line (` FAIL src/a.test.ts > …`, `--- FAIL: TestX`), or an
238 // anchored compile failure (`error[E0432]:`, `could not compile`, `error TS2322:`).
239 const fileFailure = RUNNER_FAIL_LINES.map(pattern => pattern.exec(out)?.[0].trim()).find(found => found !== undefined)
240 if (fileFailure !== undefined) return result('fail', failed(truncate(fileFailure, COMMAND_CHARS)))
241 if (summaryPass > 0) return result('pass', passedText)
242 if (isZero) return zero()
243 const compile = BUILD_FAIL_MARKERS.map(pattern => pattern.exec(out)?.[0].trim()).find(found => found !== undefined)
244 if (compile !== undefined) return result('fail', failed(truncate(compile, COMMAND_CHARS)))
245 // A build, type or lint tool's own all-clear line. Its failure lines are not
246 // read here: a later step (`ruff check --select ALL`) may have printed them.
247 const toolPass = TOOL_PASS_LINES[coreOf(segments[best.index].text).split(/\s+/)[0]]
248 if (best.strength === 'medium' && toolPass !== undefined && toolPass.test(out)) return result('pass', `${shown} passed`)
249 return result('pass', `${shown}: exit code hidden`, 'none')
250}
251
252// A medium run that is no build: a request to a local server.
253const PROBE = /^(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b/
254
255// What a command checks, in a claim's words: a test runner backs a tests
256// claim; a build, type or lint tool a build claim; anything else, nothing.
257// For "`npm test` passes", whose subject is the command itself.
258export function checkKindOf(command: string, checks: readonly DeclaredCheck[] = []): 'tests' | 'build' | null {
259 let kind: 'tests' | 'build' | null = null
260 for (const segment of split(command)) {
261 const strength = strengthOf(segment.text, checks)
262 if (strength === 'strong') return 'tests'
263 if (strength === 'medium' && !PROBE.test(coreOf(segment.text))) kind = 'build'
264 }
265 return kind
266}
267
268function runnerSummaries(out: string): string[] {
269 return out.split('\n').filter(line => RUNNER_SUMMARIES.some(pattern => pattern.test(line)))
270}
271
272// Whether the reported exit code is the runner's own: echoed right after it,
273// or nothing after it could replace it. After `npm test && git commit` an
274// exit 0 means both passed, but a non-zero one may be the commit's.
275function ownsExitCode(run: RunInput, segments: Segment[], index: number, echoed: string | null, exitCode: string): boolean {
276 if (echoed !== null) return true
277 if (isMasked(run, segments, index)) return false
278 if (exitCode === '0') return true
279 let next = index + 1
280 while (next < segments.length && segments[next - 1].connector === '|') next++
281 return segments.slice(next).every(segment => TRIVIAL_STEP.test(display(segment.text)))
282}
283
284// Whether the deciding runner runs more than once in the command, whatever
285// its arguments: red-green in one command (`vitest run test/a.test.ts`, fix,
286// `vitest run`) ends with the last invocation, whose summary decides.
287function isRepeated(segments: Segment[], index: number): boolean {
288 const runner = runnerOf(segments[index].text)
289 return segments.filter(segment => runnerOf(segment.text) === runner).length > 1
290}
291
292// A runner's identity: its program and subcommand, without paths or flags
293// (`vitest run test/x.test.ts` and `vitest run` are one runner).
294export function runnerOf(segment: string): string {
295 const words = coreOf(segment)
296 .split(/\s+/)
297 .filter(word => word !== '' && !word.startsWith('-') && !/[/\\.]/.test(word))
298 return words.slice(0, 2).join(' ')
299}
300
301function fromLastSummary(out: string): string {
302 const lines = out.split('\n')
303 for (let i = lines.length - 1; i >= 0; i--) if (SUMMARY_LINE.test(lines[i])) return lines.slice(i).join('\n')
304 return out
305}
306
307// The non-trivial steps that must succeed before the runner starts: those
308// joined to it by &&, or by ; under `set -e`.
309function stepsBefore(command: string, segments: Segment[], index: number): Segment[] {
310 const stopsOnError = /\bset\s+-\w*e/.test(command)
311 const steps: Segment[] = []
312 for (let i = index - 1; i >= 0; i--) {
313 const connector = segments[i].connector
314 if (connector !== '&&' && !(connector === ';' && stopsOnError)) break
315 if (!TRIVIAL_STEP.test(display(segments[i].text))) steps.unshift(segments[i])
316 }
317 return steps
318}
319
320function strengthOf(segment: string, checks: readonly DeclaredCheck[] = []): Strength {
321 const core = coreOf(segment)
322 if (core === '' || NOT_A_RUN.test(core) || FORMAT_ONLY.test(core)) return 'none'
323 const builtIn: Strength = STRONG.some(pattern => pattern.test(core)) ? 'strong' : MEDIUM.some(pattern => pattern.test(core)) ? 'medium' : 'none'
324 const declared = declaredStrength(core, checks)
325 // A declared check only ever raises a run's weight; what the run says
326 // (exit code, summary) decides its outcome either way.
327 return rank(declared) > rank(builtIn) ? declared : builtIn
328}
329
330// A declared check matches the command it names and that command with more
331// arguments, after the same wrappers come off both (`timeout 300 pnpm verify --ci`
332// is `pnpm verify`), never a longer word (`pnpm verifyx`).
333function declaredStrength(core: string, checks: readonly DeclaredCheck[]): Strength {
334 let strength: Strength = 'none'
335 for (const check of checks) {
336 const prefix = coreOf(check.prefix)
337 if (prefix === '' || (core !== prefix && !core.startsWith(`${prefix} `))) continue
338 const own: Strength = check.kind === 'tests' ? 'strong' : 'medium'
339 if (rank(own) > rank(strength)) strength = own
340 }
341 return strength
342}
343
344// The segment reduced to the tool that runs and its arguments, lower-cased:
345// env assignments, `cmd /c`, the call operator, npx and friends taken off the
346// front; the program named by its basename (`.\venv\Scripts\python.exe` is
347// `python`); and `node node_modules/vitest/vitest.mjs` read as `vitest`.
348function coreOf(segment: string): string {
349 let core = display(segment).toLowerCase()
350 for (;;) {
351 const before = core
352 core = core
353 .replace(/^(?:[a-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '')
354 .replace(/^(?:cmd(?:\.exe)?\s+\/c\s+|&\s+|call\s+|time\s+|env\s+|command\s+|exec\s+)/, '')
355 .replace(/^g?timeout\s+(?:(?:-[ks]|--kill-after|--signal)\s+\S+\s+|-\S+\s+)*\d+(?:\.\d+)?[smhd]?\s+/, '')
356 // Workspace and output flags between the package manager and its script.
357 .replace(/^(npm|pnpm|yarn|bun)\s+(?:(?:-r|--recursive|-s|--silent|--if-present|-ws|--workspaces|(?:--prefix|-C|--dir|--filter|-F|-w|--workspace)(?:=|\s+)\S+)\s+)+/, '$1 ')
358 .replace(/^make\s+(?:(?:-j\d*|--jobs(?:=\d+)?|-k|-s|-C\s+\S+)\s+)+/, 'make ')
359 .replace(/^(?:npx(?:\.cmd)?(?:\s+(?:--yes|-y))?\s+|pnpm\s+(?:dlx|exec)\s+|bunx\s+|yarn\s+(?:dlx|exec)\s+|npm\s+exec\s+(?:--\s+)?|(?:uv|poetry|pipenv|hatch|pdm|rye)\s+run\s+|bundle\s+exec\s+)/, '')
360 .replace(/^(?:yarn|pnpm)\s+(?=(?:vitest|jest|mocha|tsc|vue-tsc|eslint|playwright|biome)\b)/, '')
361 const [first = '', ...rest] = words(core)
362 const program = basename(first)
363 if (program === 'node' && rest.length > 0 && /[/\\]/.test(rest[0])) {
364 const pkg = /node_modules[/\\](?:@([^/\\]+)[/\\])?([^/\\]+)/.exec(rest[0])
365 core = [pkg !== null ? (pkg[1] ?? pkg[2]) : basename(rest[0]), ...rest.slice(1)].join(' ')
366 } else {
367 core = [program, ...rest].join(' ')
368 }
369 if (core === before) return core
370 }
371}
372
373function basename(word: string): string {
374 return word
375 .replace(/^["']|["']$/g, '')
376 .split(/[/\\]/)
377 .pop()!
378 .replace(/\.(?:exe|cmd|bat|ps1|mjs|cjs|js)$/, '')
379}
380
381function words(text: string): string[] {
382 return text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
383}
384
385// The segment as shown: trimmed, with redirections such as `2>&1` and a
386// subshell's parentheses taken out.
387function display(segment: string): string {
388 return segment
389 .replace(/\s+(?:\d|\*)?>>?(?:&\d|\s*\S+)/g, '')
390 .replace(/^\(+\s*|\s*\)+$/g, '')
391 .replace(/^&\s+/, '')
392 .replace(/\s+/g, ' ')
393 .trim()
394}
395
396// Splits on && || | ; & and newlines outside quotes. `>&` and `&>` are redirections.
397function split(command: string): Segment[] {
398 const segments: Segment[] = []
399 let text = ''
400 let quote: string | null = null
401 const push = (connector: string) => {
402 segments.push({ text: text.trim(), connector })
403 text = ''
404 }
405 for (let i = 0; i < command.length; i++) {
406 const c = command[i]
407 if (quote !== null) {
408 if (c === quote) quote = null
409 text += c
410 continue
411 }
412 if (c === '"' || c === "'") {
413 quote = c
414 text += c
415 continue
416 }
417 const two = command.slice(i, i + 2)
418 if (two === '&&' || two === '||') {
419 push(two)
420 i++
421 continue
422 }
423 if (c === '&' && (command[i - 1] === '>' || command[i + 1] === '>' || text.trim() === '')) {
424 text += c
425 continue
426 }
427 if (c === '|' || c === ';' || c === '\n' || c === '&') {
428 push(c === '\n' ? ';' : c)
429 continue
430 }
431 text += c
432 }
433 push('')
434 return segments.filter(segment => segment.text !== '')
435}
436
437// Whether the run's exit code can be someone else's by the time the tool reports it.
438function isMasked(run: RunInput, segments: Segment[], index: number): boolean {
439 if (segments.slice(index).some(segment => segment.connector === '||')) return true
440 if (run.tool === 'PowerShell') {
441 return segments.slice(index + 1).some((segment, i) => segments[index + i].connector !== '|' && !PS_NOT_NATIVE.test(display(segment.text)))
442 }
443 const connector = segments[index].connector
444 if (connector === '|') return !/\bpipefail\b/.test(run.command)
445 return connector === ';' || connector === '&'
446}
447
448// An exit status the command printed itself: `npm test; echo exit=$?` printing
449// "exit=0", or PowerShell's `"build: $LASTEXITCODE"`. Read only where the echo
450// directly follows the run's statement. In Bash, `$?` after `npm test | grep`
451// is grep's status (without pipefail), so it says nothing about the runner.
452function echoedStatus(run: RunInput, segments: Segment[], index: number, out: string): string | null {
453 if (run.tool === 'Bash' && segments[index].connector === '|' && !/\bpipefail\b/.test(run.command)) return null
454 let next = index + 1
455 while (next < segments.length && segments[next - 1].connector === '|') next++
456 const echo = segments[next]
457 if (echo === undefined) return null
458 const variable = run.tool === 'PowerShell' ? /\$LASTEXITCODE\b/i : /\$\?/
459 const found = variable.exec(echo.text)
460 if (found === null) return null
461 const label = echo.text
462 .slice(0, found.index)
463 .replace(/^(?:echo|write-output|write-host|printf)\s+/i, '')
464 .replace(/^["']/, '')
465 if (label.trim() === '') return null
466 const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
467 return new RegExp(`^\\s*${escaped}\\s*(\\d+)\\s*$`, 'm').exec(out)?.[1] ?? null
468}
469
470function isSwallowed(segments: Segment[], index: number): boolean {
471 for (let i = index; i < segments.length - 1; i++) {
472 if (segments[i].connector === '||' && SWALLOW.test(segments[i + 1].text)) return true
473 }
474 return false
475}
476
477function largest(text: string, patterns: readonly RegExp[]): number {
478 let most = 0
479 for (const pattern of patterns) {
480 for (const match of text.matchAll(pattern)) most = Math.max(most, Number(match[1]))
481 }
482 return most
483}
484
485function rank(strength: Strength): number {
486 return strength === 'strong' ? 2 : strength === 'medium' ? 1 : 0
487}
488hooks/core/ledger.ts 84 lines1// The session's ordered record of edits and runs. Pure: every function returns
2// a new ledger, so the value can live in `$.state` as it is.
3
4import { runnerOf } from './evidence.ts'
5import type { ClaimKind, Evidence, Ledger, LedgerEntry } from './types.ts'
6
7export const LEDGER_LIMIT = 500
8
9export function emptyLedger(): Ledger {
10 return { nextSeq: 0, entries: [] }
11}
12
13export function recordEdit(l: Ledger, path: string, at: number, agentId?: string): Ledger {
14 return append(l, agentId === undefined ? { seq: l.nextSeq, at, kind: 'edit', path } : { seq: l.nextSeq, at, agentId, kind: 'edit', path })
15}
16
17export function recordRun(l: Ledger, ev: Evidence, at: number, agentId?: string): Ledger {
18 return append(l, agentId === undefined ? { seq: l.nextSeq, at, kind: 'run', evidence: ev } : { seq: l.nextSeq, at, agentId, kind: 'run', evidence: ev })
19}
20
21// True when the turn that started at `turnStartSeq` has something to answer for:
22// an edit made during it, or an earlier edit no passing check had covered yet.
23// Known v1 trade-off: any passing check covers an earlier edit, so a medium pass
24// (tsc) makes a later turn's "all tests pass" go unjudged rather than ⚠️.
25// Whether a code edit was made at or after `seq` (during the turn that started there).
26// A verdict on a turn without one is about an edit carried over from earlier.
27export function editedSince(l: Ledger, seq: number): boolean {
28 return l.entries.some(entry => entry.kind === 'edit' && entry.seq >= seq)
29}
30
31export function needsJudging(l: Ledger, turnStartSeq: number): boolean {
32 if (l.entries.some(entry => entry.kind === 'edit' && entry.seq >= turnStartSeq)) return true
33 const before = l.entries.filter(entry => entry.seq < turnStartSeq)
34 const lastEdit = lastEditSeq(before)
35 if (lastEdit < 0) return false
36 return !before.some(entry => entry.seq > lastEdit && entry.kind === 'run' && entry.evidence.outcome === 'pass' && entry.evidence.strength !== 'none')
37}
38
39// The latest pass or fail after the last edit that can back a claim of `kind`:
40// a tests claim needs a strong run; a generic claim takes strong or medium; a
41// build claim is judged by build, typecheck and lint runs (medium), and a
42// passing test run backs it (it compiled) while a failing one says nothing
43// about the build.
44export function latestQualifyingRun(l: Ledger, kind: ClaimKind, isOutOfScope: (ev: Evidence) => boolean = () => false): Evidence | null {
45 const lastEdit = lastEditSeq(l.entries)
46 let testPass: Evidence | null = null
47 for (let i = l.entries.length - 1; i >= 0; i--) {
48 const entry = l.entries[i]
49 if (entry.seq <= lastEdit) break
50 if (entry.kind !== 'run') continue
51 const { strength, outcome } = entry.evidence
52 if (outcome !== 'pass' && outcome !== 'fail') continue
53 // A failure in another area than the claim's says nothing about it: look further back.
54 if (outcome === 'fail' && isOutOfScope(entry.evidence)) continue
55 // A failing run of part of the suite after a full pass of the same runner,
56 // with no edit between, is a mutation check or a probe: the pass stands.
57 if (outcome === 'fail' && entry.evidence.subset === true && passedBefore(l.entries, i, lastEdit, entry.evidence.command)) continue
58 if (kind === 'build') {
59 if (strength === 'medium') return entry.evidence
60 if (strength === 'strong' && outcome === 'pass' && testPass === null) testPass = entry.evidence
61 continue
62 }
63 if (strength === 'strong' || (strength === 'medium' && kind === 'generic')) return entry.evidence
64 }
65 return testPass
66}
67
68// Whether the same runner passed earlier than entry `index`, after the last edit.
69function passedBefore(entries: readonly LedgerEntry[], index: number, lastEdit: number, command: string): boolean {
70 const runner = runnerOf(command)
71 return entries.slice(0, index).some(entry =>
72 entry.seq > lastEdit && entry.kind === 'run' && entry.evidence.outcome === 'pass' && entry.evidence.strength !== 'none' && runnerOf(entry.evidence.command) === runner)
73}
74
75function lastEditSeq(entries: readonly LedgerEntry[]): number {
76 for (let i = entries.length - 1; i >= 0; i--) if (entries[i].kind === 'edit') return entries[i].seq
77 return -1
78}
79
80function append(l: Ledger, entry: LedgerEntry): Ledger {
81 const entries = [...l.entries, entry]
82 return { nextSeq: l.nextSeq + 1, entries: entries.length > LEDGER_LIMIT ? entries.slice(-LEDGER_LIMIT) : entries }
83}
84hooks/core/types.ts 26 lines1// Shared types of the Receipts core. Pure data: no `$`, no engine imports.
2
3export type ClaimKind = 'tests' | 'build' | 'generic'
4export type Claim = { sentence: string; kind: ClaimKind; certainty: 'sure' | 'maybe' }
5
6// A command the person declared as a check (the `checks` setting): a test run
7// or a build run, matched by its command and any arguments after it.
8export type DeclaredCheck = { kind: 'tests' | 'build'; prefix: string }
9
10export type Strength = 'strong' | 'medium' | 'none'
11export type Outcome = 'pass' | 'fail' | 'pending' | 'zero-tests'
12export type RunInput = { tool: 'Bash' | 'PowerShell'; command: string; isError: boolean; text: string; backgrounded: boolean }
13// `subset`: a failing run that ran only part of its suite (a name filter, "N filtered out").
14export type Evidence = { strength: Strength; outcome: Outcome; command: string; summary: string; subset?: true }
15
16export type LedgerEntry =
17 | { seq: number; at: number; agentId?: string; kind: 'edit'; path: string }
18 | { seq: number; at: number; agentId?: string; kind: 'run'; evidence: Evidence }
19export type Ledger = { nextSeq: number; entries: LedgerEntry[] }
20
21export type VerdictKind = 'verified' | 'unverified' | 'contradicted'
22export type Verdict = { kind: VerdictKind; claim: Claim; evidence: Evidence | null }
23
24export type Mode = 'A' | 'B' | 'C'
25export type Action = { sendBack: string | null; nudge: string | null }
26hooks/core/verdict.ts 50 lines1// Turns claims and the ledger into a verdict, and a verdict into what to do.
2
3import { nudgeText, sendBackText } from './copy.ts'
4import { latestQualifyingRun, needsJudging } from './ledger.ts'
5import type { Action, Claim, Ledger, Mode, Verdict, VerdictKind } from './types.ts'
6
7const SEVERITY: Record<VerdictKind, number> = { verified: 0, unverified: 1, contradicted: 2 }
8
9// The worst verdict over the claims (contradicted > unverified > verified), or
10// null when there is no claim or nothing that needs judging. On a tie the
11// tests claim is quoted: "All tests pass." says more than "Fixed.".
12export function judge(claims: Claim[], l: Ledger, turnStartSeq: number): Verdict | null {
13 if (claims.length === 0 || !needsJudging(l, turnStartSeq)) return null
14 let worst: Verdict | null = null
15 for (const claim of claims) {
16 // "The website tests pass" is no lie because a later `cargo test` failed: a
17 // failure counts against a claim only when it can be the claim's own area.
18 const evidence = latestQualifyingRun(l, claim.kind, ev => !coversScope(claim.sentence, ev.command))
19 const kind: VerdictKind = evidence === null ? 'unverified' : evidence.outcome === 'pass' ? 'verified' : 'contradicted'
20 const isWorse = worst === null || SEVERITY[kind] > SEVERITY[worst.kind]
21 const isMoreSpecific = worst !== null && SEVERITY[kind] === SEVERITY[worst.kind] && claim.kind === 'tests' && worst.claim.kind !== 'tests'
22 if (isWorse || isMoreSpecific) worst = { kind, claim, evidence }
23 }
24 return worst
25}
26
27// Words that qualify tests without naming an area of the code.
28const UNSCOPED = new Set([
29 'all', 'the', 'unit', 'new', 'existing', 'every', 'our', 'these', 'those', 'my', 'your', 'its', 'their', 'other',
30 'remaining', 'relevant', 'related', 'both', 'test', 'full', 'whole', 'entire', 'e2e', 'integration', 'end-to-end',
31 'regression', 'failing', 'passing', 'browser', 'visual', 'smoke', 'snapshot', 'and', 'of', 'type', 'site', 'local',
32])
33
34// Whether a failing run can be about the area the claim names. A claim that
35// names none ("All tests pass") is covered by any run; one that names an area
36// ("the website tests", "parser tests") only by a command that mentions it.
37function coversScope(sentence: string, command: string): boolean {
38 const scope = /\b([a-z][\w-]*)\s+(?:tests?|specs?|suite|build)\b/i.exec(sentence)?.[1]?.toLowerCase()
39 if (scope === undefined || UNSCOPED.has(scope) || /^\d/.test(scope)) return true
40 const stem = scope.replace(/(?:'s|s)$/, '')
41 return command.toLowerCase().includes(stem)
42}
43
44export function decide(v: Verdict, mode: Mode, sentBackThisTurn: boolean, stopHookActive: boolean): Action {
45 if (v.kind === 'verified' || mode === 'A') return { sendBack: null, nudge: null }
46 const canSendBack = !sentBackThisTurn && !stopHookActive
47 if (mode === 'C' || v.kind === 'contradicted') return { sendBack: canSendBack ? sendBackText(v) : null, nudge: null }
48 return { sendBack: null, nudge: nudgeText(v) }
49}
50hooks/core/visibility.ts 47 lines1// What a verdict shows, in one place so it is easy to tune (lead's defaults,
2// from the replay of real sessions). Every verdict is kept in the pane's
3// history whatever this says; this decides what reaches the person unasked.
4//
5// quiet (the default):
6// ❌ band, stamp, send-back (modes B and C)
7// ⚠️ tests/build claim, edited this turn band, stamp, nudge (B), send-back (C)
8// ⚠️ generic, or the edit was earlier pane only
9// ✅ tests/build claim band, stamp
10// ✅ generic pane only
11// all: every verdict on the band and stamped; every ⚠️ nudged (B) or sent back (C).
12
13import type { ClaimKind, VerdictKind } from './types.ts'
14
15export type Verbosity = 'quiet' | 'all'
16export type Visibility = { band: boolean; stamp: boolean; nudge: boolean; sendBackEligible: boolean }
17
18// The parts of a verdict that matter here: the replay passes its own TurnResult
19// fields, the mod a Verdict.
20export type Shown = { kind: VerdictKind; claim: { kind: ClaimKind } }
21
22export function visibility(verdict: Shown, carriedOver: boolean, verbosity: Verbosity = 'quiet'): Visibility {
23 const isSpecific = verdict.claim.kind !== 'generic'
24 if (verdict.kind === 'contradicted') return { band: true, stamp: true, nudge: false, sendBackEligible: true }
25 if (verdict.kind === 'unverified') {
26 const surfaces = verbosity === 'all' || (isSpecific && !carriedOver)
27 return { band: surfaces, stamp: surfaces, nudge: surfaces, sendBackEligible: surfaces }
28 }
29 const shows = verbosity === 'all' || isSpecific
30 return { band: shows, stamp: shows, nudge: false, sendBackEligible: false }
31}
32
33// How long the band keeps its verdict. A newer verdict replaces it whatever
34// this says; [Dismiss] hides it. Otherwise:
35// ✅ until the person's next prompt
36// ⚠️ until the next verdict; but once a prompt carried its reminder (mode
37// B), only until that prompt's turn ends: Claude has had its say
38// ❌ until the next verdict
39// `judged` is whether the turn that ended gave a verdict of its own.
40export type BandMoment = { at: 'prompt' } | { at: 'turn-end'; reminded: boolean; judged: boolean }
41
42export function bandStays(kind: VerdictKind, moment: BandMoment): boolean {
43 if (moment.at === 'prompt') return kind !== 'verified'
44 if (moment.judged) return true
45 return !(kind === 'unverified' && moment.reminded)
46}
47hooks/smartcheck.ts 14 lines1// The opt-in smart check (spec §4): a short model question for the sentences
2// the lexicon marks 'maybe'. Pure parts only; register.tsx makes the call
3// (`$` may only be passed to functions declared in register.tsx itself).
4
5export const SMART_MODEL = 'haiku'
6export const SMART_MAX_TOKENS = 5
7export const SMART_SYSTEM =
8 'Answer YES or NO only. Does this sentence claim that code work is finished, fixed, or verified as working?'
9
10// Whether the model's reply keeps the claim: an answer that starts with YES.
11export function isYes(reply: string): boolean {
12 return /^\s*YES\b/i.test(reply)
13}
14hooks/commands.ts 81 lines1// The /receipts command, parsed into a plan. Pure: register.tsx carries it out
2// (`$` may only be passed to functions declared in register.tsx itself).
3
4export const COMMAND = 'receipts'
5export const ARGUMENT_HINT = '[mode A|B|C | verbosity quiet|all | on | off | smart on|off | checks | check add <command> tests|build | check remove <command>]'
6export const DESCRIPTION = 'Receipts: show judged claims, or change the mode'
7export const USAGE = `Usage: /receipts ${ARGUMENT_HINT}`
8
9import { checkEntry } from './core/checks.ts'
10import type { DeclaredCheck } from './core/types.ts'
11
12export type CommandPlan =
13 | { kind: 'open' }
14 | { kind: 'checks'; action: 'list' }
15 | { kind: 'checks'; action: 'add'; check: DeclaredCheck }
16 | { kind: 'checks'; action: 'remove'; prefix: string }
17 | { kind: 'set'; key: 'receipts.mode' | 'receipts.verbosity' | 'receipts.enabled' | 'receipts.smartCheck'; value: string | boolean; done: string }
18 | { kind: 'usage' }
19
20export function parseCommand(args: string): CommandPlan {
21 const checks = parseCheckCommand(args.trim())
22 if (checks !== null) return checks
23 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
24 const [first, second] = words
25 if (words.length === 0) return { kind: 'open' }
26 if (words.length === 2 && first === 'mode' && ['a', 'b', 'c'].includes(second)) {
27 const mode = second.toUpperCase()
28 return { kind: 'set', key: 'receipts.mode', value: mode, done: `Receipts mode is now ${mode}.` }
29 }
30 if (words.length === 2 && first === 'verbosity' && (second === 'quiet' || second === 'all')) {
31 return { kind: 'set', key: 'receipts.verbosity', value: second, done: `Receipts verbosity is now ${second}.` }
32 }
33 if (words.length === 1 && (first === 'on' || first === 'off')) {
34 return { kind: 'set', key: 'receipts.enabled', value: first === 'on', done: `Receipts is now ${first}.` }
35 }
36 if (words.length === 2 && first === 'smart' && (second === 'on' || second === 'off')) {
37 return { kind: 'set', key: 'receipts.smartCheck', value: second === 'on', done: `Receipts smart check is now ${second}.` }
38 }
39 return { kind: 'usage' }
40}
41
42// `checks`, `check list`, `check add <command> tests|build`, `check remove
43// <command>`; the command as typed (case kept), in quotes or not.
44function parseCheckCommand(args: string): CommandPlan | null {
45 const match = /^(checks|check)(?:\s+(list|add|remove))?\s*(.*)$/i.exec(args)
46 if (match === null) return null
47 const action = (match[2] ?? 'list').toLowerCase()
48 const rest = match[3].trim()
49 if (action === 'list') return rest === '' ? { kind: 'checks', action: 'list' } : { kind: 'usage' }
50 if (action === 'remove') {
51 const prefix = unquote(rest)
52 return prefix === '' ? { kind: 'usage' } : { kind: 'checks', action: 'remove', prefix }
53 }
54 const add = /^(.*\S)\s+(tests?|build)$/i.exec(rest)
55 const prefix = add === null ? '' : unquote(add[1])
56 if (add === null || prefix === '') return { kind: 'usage' }
57 return { kind: 'checks', action: 'add', check: { kind: add[2].toLowerCase().startsWith('test') ? 'tests' : 'build', prefix } }
58}
59
60function unquote(text: string): string {
61 return text.trim().replace(/^(["'])(.*)\1$/, '$2').trim()
62}
63
64// What a checks plan does to the setting's checks: the list to store, if it
65// changes, and the words to answer with. Pure.
66export function editChecks(current: readonly DeclaredCheck[], plan: Extract<CommandPlan, { kind: 'checks' }>): { value?: string[]; text: string } {
67 const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
68 if (plan.action === 'list') {
69 return current.length === 0
70 ? { text: 'No checks declared. Add one with /receipts check add <command> tests|build.' }
71 : { text: ['Receipts checks:', ...current.map(c => ` ${checkEntry(c)}`)].join('\n') }
72 }
73 if (plan.action === 'add') {
74 const others = current.filter(c => !same(c.prefix, plan.check.prefix))
75 return { value: [...others, plan.check].map(checkEntry), text: `Receipts now counts ${plan.check.prefix} as a ${plan.check.kind === 'tests' ? 'test' : 'build'} check.` }
76 }
77 const kept = current.filter(c => !same(c.prefix, plan.prefix))
78 if (kept.length === current.length) return { text: `No check named ${plan.prefix}.` }
79 return { value: kept.map(checkEntry), text: `Receipts no longer counts ${plan.prefix} as a check.` }
80}
81