Check what Claude claims against what it ran. When an answer says the tests pass, the build is clean, or something was committed, a line under it says whether…

Check what Claude claims against what it ran. "All tests pass." No test command ran. "I committed the changes." No git commit in the turn. "Verified." Nothing was read or run. A long-running issue on the Claude Code tracker documents months of exactly this.
After a turn, when the answer makes one of those claims, a line under it says whether a matching command actually ran this turn and whether it succeeded. The line is for you; Claude doesn't read it, and no follow-up turn is started.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install trust-but-verify@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/trust-but-verify
Nothing to do. Under an answer that makes a claim:
trust-but-verify: ✔ tests pass, backed by `npm test -- --run`
trust-but-verify: ✘ claims tests pass, but no matching command ran this turn
trust-but-verify: ✘ claims build succeeds, but `npm run build` failed
| Claim in the answer | Evidence looked for in this turn's Bash calls |
|---|---|
| tests pass, tests are green, ran the tests | npm test, pytest, go test, cargo test, jest, vitest, mocha, phpunit, rspec, make test, dotnet test, mvn test, gradle test, and others |
| build succeeds, compiles cleanly, type-checks pass | npm run build, tsc, cargo build, go build, make, gradle build, mvn package, dotnet build, xcodebuild, swift build |
| lint is clean | eslint, npm run lint, ruff, flake8, pylint, golangci-lint, cargo clippy, rubocop, biome |
| committed | git commit |
| pushed | git push |
| verified, confirmed, validated | Any command, read, search, or MCP call. This one only notes what ran; it can't judge whether that was enough. |
| Command | What it does |
|---|---|
/claims | Show the verdicts from this session. |
/claims off, /claims on | Hide or show the verdict line. Remembered. |
From claude plugin validate ./mods/trust-but-verify:
hooks: session.start, command.run{command=claims}, turn.start, tool.call, turn.complete
calls: $.command.register, $.store.get, $.store.set, $.ui.log
tool.call observes every call of the main conversation and passes it through unchanged. Only the tool name, a Bash command's text, and whether it errored are kept, for the current turn.turn.complete returns a line of text under the answer. It never starts a turn, calls a model, or changes what Claude reads.$.store holds the on/off flag. No files, processes, or network.claude plugin validate --strict and claude plugin test pass. /claims answered from a live claude -p session.receipt mod in the ecosystem names unverified claims similarly; this one checks them against the commands that ran.MIT, see the repository root.
hooks/register.ts 103 lines1// trust-but-verify: check what Claude claims against what it ran.
2//
3// /claims show the verdicts from this session
4// /claims off|on hide or show the verdict line
5//
6// When an answer claims the tests pass, the build is clean, lint is clean,
7// something was committed or pushed, or something was "verified", a line
8// under the answer says whether a matching command actually ran this turn
9// and succeeded. The line is for you; Claude doesn't read it. No follow-up
10// turn is started.
11
12type Call = { tool: string; command: string; isError: boolean }
13type Claim = { name: string; claim: RegExp; evidence: RegExp }
14
15const CLAIMS: Claim[] = [
16 { name: 'tests pass', claim: /\b(?:all |the )?tests? (?:now |all |are |is )?(?:pass(?:es|ing)?|green|succeed(?:s|ed)?)\b|\btest suite (?:passes|is green)\b|\bran the tests?\b/i, evidence: /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bpytest\b|\bgo test\b|\bcargo test\b|\bjest\b|\bvitest\b|\bmocha\b|\bphpunit\b|\brspec\b|\bmake test\b|\bdotnet test\b|\bmvn (?:test|verify)\b|\bgradle(?:w)? test\b|\bctest\b|\bbundle exec rspec\b|\bmix test\b/ },
17 { name: 'build succeeds', claim: /\b(?:build|builds|compiles?|compiled)\s+(?:succeeds|successfully|passes|cleanly|fine|without errors)\b|\bbuilt successfully\b|\btype-?checks? (?:pass|passes|clean)\b/i, evidence: /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck|tsc)\b|\btsc\b|\bcargo (?:build|check)\b|\bgo (?:build|vet)\b|\bmake\b(?!\s+test)|\bgradle(?:w)?\s+(?:build|assemble)\b|\bmvn (?:compile|package|install)\b|\bdotnet build\b|\bxcodebuild\b|\bswift build\b/ },
18 { name: 'lint is clean', claim: /\blint(?:er|ing)? (?:passes|is clean|clean|succeeds)\b|\bno lint (?:errors|warnings)\b/i, evidence: /\beslint\b|\b(?:npm|pnpm|yarn|bun)\s+run\s+lint\b|\bruff\b|\bflake8\b|\bpylint\b|\bgolangci-lint\b|\bcargo clippy\b|\brubocop\b|\bbiome\b|\bprettier --check\b/ },
19 { name: 'committed', claim: /\b(?:I|changes?|it|this)(?:'ve| have)? (?:been )?committed\b|\bcommitted (?:the|these|those|my) (?:changes?|fix|work)\b/i, evidence: /\bgit (?:-C \S+ )?commit\b/ },
20 { name: 'pushed', claim: /\bpushed (?:to|the|my|it|them|this|everything|up)\b|\b(?:I(?:'ve| have)? |changes? (?:have |has )?(?:been )?)pushed\b/i, evidence: /\bgit (?:-C \S+ )?push\b/ },
21]
22const VERIFIED = /\b(?:I(?:'ve| have)? )?(?:verified|confirmed|validated|double-checked)\b(?! that you| with you)/i
23
24let enabled = true
25let calls: Call[] = []
26const verdicts: string[] = []
27
28function reset() {
29 calls = []
30}
31
32function commandOf(e): string {
33 if (e.tool === 'Bash') return String(e.command ?? '')
34 return ''
35}
36
37function judge(answer: string): string[] {
38 const lines: string[] = []
39 for (const c of CLAIMS) {
40 if (!c.claim.test(answer)) continue
41 const matching = calls.filter((x) => x.tool === 'Bash' && c.evidence.test(x.command))
42 if (!matching.length) lines.push('✘ claims ' + c.name + ', but no matching command ran this turn')
43 else if (matching.every((x) => x.isError)) lines.push('✘ claims ' + c.name + ', but `' + shorten(matching[matching.length - 1].command) + '` failed')
44 else lines.push('✔ ' + c.name + ', backed by `' + shorten(matching.filter((x) => !x.isError).pop()!.command) + '`')
45 }
46 if (VERIFIED.test(answer) && !lines.length) {
47 const ran = calls.filter((x) => x.tool === 'Bash' || x.tool === 'Read' || x.tool === 'Grep' || x.tool === 'Glob' || x.tool.startsWith('mcp__'))
48 lines.push(ran.length ? '· says "verified"; ' + ran.length + ' tool call(s) ran this turn, judge for yourself' : '✘ says "verified", but no command or read ran this turn')
49 }
50 return lines
51}
52
53function shorten(command: string): string {
54 const one = command.replace(/\s+/g, ' ').trim()
55 return one.length > 60 ? one.slice(0, 59) + '…' : one
56}
57
58export function register(on) {
59 on('session.start', async ($, e, next) => {
60 try {
61 enabled = (await $.store.get('enabled')) !== false
62 } catch {
63 enabled = true
64 }
65 try {
66 await $.command.register({ name: 'claims', description: 'Show whether answers that claim tests pass or builds succeed were backed by a command', argumentHint: '[on | off]' })
67 } catch (error) {
68 $.ui.log('could not register /claims: ' + error)
69 }
70 return next(e)
71 })
72
73 on('command.run', { command: 'claims' }, async ($, e) => {
74 const args = e.args.trim()
75 if (args === 'on' || args === 'off') {
76 enabled = args === 'on'
77 await $.store.set('enabled', enabled)
78 return { text: enabled ? 'trust-but-verify on.' : 'trust-but-verify off. No verdict lines.' }
79 }
80 return { text: (enabled ? 'trust-but-verify on' : 'trust-but-verify off') + '. Verdicts this session:\n' + (verdicts.length ? verdicts.map((v) => ' ' + v).join('\n') : ' none yet') }
81 }).catch(async () => ({ text: 'trust-but-verify: the command failed, so nothing changed.' }))
82
83 on('turn.start', async ($, e, next) => {
84 if (typeof e.agentId !== 'string') reset()
85 return next(e)
86 }).catch(async ($, e, next) => next(e))
87
88 on('tool.call', async ($, e, next) => {
89 const result = await next(e)
90 if (typeof e.agentId !== 'string' && !result.deny) calls.push({ tool: e.tool, command: commandOf(e), isError: result.isError === true })
91 return result
92 }).catch(async ($, e, next) => next(e))
93
94 on('turn.complete', async ($, e, next) => {
95 if (!enabled || typeof e.agentId === 'string' || e.isAborted || typeof e.answer !== 'string') return next(e)
96 const lines = judge(e.answer)
97 reset()
98 if (!lines.length) return next(e)
99 verdicts.push(...lines)
100 return { text: 'trust-but-verify: ' + lines.join('\n' + ' '.repeat(18)) }
101 }).catch(async ($, e, next) => next(e))
102}
103