SLOPSHOPPER

receipts

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-15candygoogy/notary/plugins/receipts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · receipts
│ ┃ Receipts ✕ › fix the failing auth test and add an audit log call │ ┃ No judged turns yet. Receipts judges an │ ┃ answer that claims success after an edit. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /receipts │ ⎿ receipts: Receipts pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Receipts
No judged turns yet. Receipts judges an answer that claims success after an edit.
README

Receipts

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.

When a turn is judged

At the end of each turn, Receipts judges the final answer only if both of these hold:

  1. The answer contains at least one success claim.
  2. Something needs checking: code was edited during this turn, or a code edit from an earlier turn had no passing check after it when this turn began.

So "Done." after answering a question gets no verdict. Neither does "Committed." right after a turn whose tests passed.

Which edits count

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:

  • prose and notes: .md files, CLAUDE.md, memory files, .claude/ and .agent/;
  • scratch and temp folders;
  • build output such as target/, dist/ and node_modules/;
  • anything outside the session's working folder.

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.

Verdicts

VerdictRule
✅ VerifiedThe latest qualifying check after the last edit passed.
⚠️ UnverifiedNo qualifying check after the last edit. Checks still running in the background, and runs that found no tests, don't qualify.
❌ ContradictedThe latest qualifying check after the last edit failed.

When an answer has several claims, the worst verdict wins.

What you see: verbosity

Every verdict goes into the /receipts pane. What reaches you unasked depends on the verbosity setting.

quiet (the default):

VerdictBand above the promptStamp under the answerMode BMode C
❌ any claimuntil the next verdictyessent back oncesent back once
⚠️ tests or build claim, code edited this turnuntil the next verdict; in mode B, until Claude has answered the reminderyesreminder with your next promptsent back once
⚠️ general claim, or the edit was in an earlier turnpane onlypane onlynothingnothing
✅ tests or build claimuntil your next promptyesnothingnothing
✅ general claimpane onlypane onlynothingnothing

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.

Claims

Receipts splits the answer into sentences and looks for phrases like these:

  • "I've fixed / implemented / resolved / completed / added / updated …"
  • "It's done / fixed / working", "Everything works", "The build succeeds"
  • "All tests pass", "Tests are passing", "Tests: 673 pass"
  • "No (type / lint / build) errors", "Verified that it works", "Ready to merge"
  • a closing line on its own: "Done.", "Fixed.", "All set.", "✅"

Each claim has a kind, which decides what can back it:

  • tests: mentions tests, specs or a suite. It needs a test run.
  • build: mentions the build, compiling, types, 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.
  • general: anything else ("Fixed.", "It works"). Accepts a test run or a build, type check, lint or localhost check.

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:

  • hedges: should, probably, likely, might, I think, hopefully;
  • disclaims: "I haven't run", "untested", "you'll need to run";
  • negates: "not fixed yet", "still fails", "no longer passes";
  • asks a question, or puts a condition before the claim ("If the tests pass, …", "Tell me when it's done");
  • talks about the future: "will pass", "going to";
  • tells someone what to do: "Confirm the tests pass, then …";
  • explains or introduces rather than reports: "Why it works:", "Here is what changed:", "I'll …", "Next, …";
  • sits in an answer that reports a current failure honestly: "The build succeeds, 3 tests are still failing."

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.

Checks

Every Bash and PowerShell command Claude runs is classified by what it can prove.

Test runs back any claim:

  • npm, pnpm, yarn or bun test
  • vitest, jest, mocha, playwright test
  • node --test, bun test, deno test
  • pytest, python -m pytest or unittest, tox, nox
  • cargo test and cargo nextest, go test, dotnet test
  • gradle test or check, mvn test or verify
  • Invoke-Pester, rspec, phpunit, ctest, make test or make check

Builds and checks back general claims:

  • tsc and vue-tsc
  • npm run build, typecheck or lint
  • cargo build, check or clippy
  • go build, go vet, dotnet build
  • mypy, pyright, ruff, eslint, biome
  • curl, wget, iwr or Invoke-RestMethod against localhost

Not evidence:

  • reading, listing and git commands
  • installs
  • formatting checks: 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
  • anything ending in || true, || exit 0 or || :
  • runs that timed out, were interrupted, or that you rejected

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.

Your own checks

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.

Pass or fail

  • The runner's exit code decides. A test named "fails without the fix" doesn't fail a passing run.
  • When the exit code is hidden, the runner's own summary line decides ("Tests 1 failed | 41 passed", 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.
  • Bash: a pipe (npm test | tail -5) hides the exit code. set -o pipefail restores it.
  • PowerShell: a pipe into a cmdlet (npm test | Select-Object -Last 5) does not hide the native command's exit code. A later statement that runs another program does.
  • Output with no tests ("no tests ran", "No tests found") counts as no evidence. So does a command that never started ("command not found", "is not recognized as a name of a cmdlet").

Pester only sets an exit code with -CI, so its summary is always read.

Settings

SettingValuesDefaultCommand
modeA (warn), B (warn, nudge, send back on ❌), C (send back on ⚠️ or ❌)B`/receipts mode A\B\C`
enabledon or offon/receipts on, /receipts off
smartCheckon or offoff`/receipts smart on\off`
verbosityquiet (flag ❌ and unchecked tests or build claims; the rest goes to the pane) or allquiet`/receipts verbosity quiet\all`
stampon or offon/config
checksyour 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.

Sending back and nudging

  • Send back stops Claude from ending the turn and gives it the reason, for example: 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.
  • Nudge (mode B, ⚠️) attaches a short note to your next prompt that only Claude sees: 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.

Limits

  • English only. Claims in other languages are not judged.
  • It checks that checks ran, not what they cover. A passing suite that doesn't test your change still counts as ✅.
  • Shell edits don't count as edits. Files changed by sed -i, Set-Content or a script don't reopen verification.
  • A deliberate "break it, run one test (by name, or with N filtered out), put it back" check after a full pass doesn't overturn that pass.
  • The flip side: a script that breaks the code and doesn't restore it leaves the earlier ✅ standing.
  • A revert from the Changes pane isn't an edit to Receipts. It changes files outside Claude's tool calls, so a ✅ given before the revert still stands until Claude edits again.
  • Background runs with no result yet are not evidence (⚠️ until they finish).
  • Long, mixed sentences ("Fixed the parser, but the timezone case is left") are skipped unless smart check is on.
  • Subagents' own reports aren't judged. Their edits and test runs count toward the main conversation's claims.
  • Desktop app. The Code tab doesn't show the stamp line under the answer. The band, the pane and the reminder to Claude work there.

Failure is quiet

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.

Windows

Receipts is tested on Windows with the PowerShell and Git Bash tools. The tests cover:

  • CRLF output and CRLF answers
  • drive letters and path case
  • npm.cmd
  • Pester without -CI
  • localized dotnet test output
  • runners missing from a short PATH (the desktop app)

The manual checklist is in docs/windows-checklist.md.

Source 18 files
hooks/register.tsx 481 lines
1import 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}
481
hooks/core/checks.ts 35 lines
1// 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}
35
hooks/core/claims.ts 213 lines
1// 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}
213
hooks/core/copy.ts 30 lines
1// 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}
30
hooks/core/edits.ts 44 lines
1// 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}
44
hooks/core/evidence.ts 488 lines
1// 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}
488
hooks/core/ledger.ts 84 lines
1// 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}
84
hooks/core/types.ts 26 lines
1// 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 }
26
hooks/core/verdict.ts 50 lines
1// 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}
50
hooks/core/visibility.ts 47 lines
1// 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}
47
hooks/smartcheck.ts 14 lines
1// 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}
14
hooks/commands.ts 81 lines
1// 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