Warns when a turn changed code and claimed success without running a build, test or lint

"Verify, then speak": warns when a turn changed code, claimed success, and never ran a build, test or lint.
Edit, Write or NotebookEdit on a file that is not .md or .txt. Code changed through Bash is not seen.Bash command with a segment (split on &&, ;, ||) that is xcodebuild, swift build|test, npm|pnpm|yarn [run] build|test|lint, tsc, vitest, jest, pytest, cargo build|test|check|clippy, go build|test|vet, make, gradle, claude plugin test|validate, swiftlint, eslint, or an rtk err|test wrapper. rtk, sudo, npx and VAR=value prefixes are stripped. A check that fails still counts: it was run.verify-before-claim: success claimed without running a build or test this turn appears and a band above the prompt shows the same message with a Dismiss button.| Command | Effect |
|---|---|
/verify-before-claim off | Stop checking (and clear the band). |
/verify-before-claim on | Resume. |
/verify-before-claim status | Show the last turn: code changed, checks run, claim found. |
claude --plugin-dir /path/to/ModsTools/mods/verify-before-claim
pas, n', not, n't, never, no), a conditional (si, if, once, unless) or ending with ? is never a claim. This avoids "not fixed yet" and "is it fixed?" but also misses real claims such as "there is no error left, fixed". Other phrasings ("should be good now") are not detected, and a bare "done" can be a false positive.Bash (sed -i, redirects) do not count as code changed.claude plugin validate mods/verify-before-claim
claude plugin test mods/verify-before-claim # 17 testshooks/register.tsx 90 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { LastTurn } from '../types'
5import { findClaim, isCheckCommand, isCodePath } from './verify'
6
7const MESSAGE = 'verify-before-claim: success claimed without running a build or test this turn'
8const isWarning = atom({ plugin: 'verify-before-claim', key: 'isWarning' } as const, false)
9const last = atom({ plugin: 'verify-before-claim', key: 'last' } as const, null)
10
11const yesNo = (value: boolean) => (value ? 'yes' : 'no')
12
13export const register: Register = on => {
14 let isEnabled = true
15 let codeChanged = false
16 let checks = 0
17
18 on('session.start', async ($, e, next) => {
19 await $.command.register({
20 name: 'verify-before-claim',
21 description: 'Warn when success is claimed without a check (off | on | status)',
22 })
23
24 return next(e)
25 })
26
27 on('command.run', { command: 'verify-before-claim' }, async ($, e) => {
28 const arg = e.args.trim()
29 if (arg === 'off') {
30 isEnabled = false
31 await update($, isWarning, () => false)
32 return { text: 'verify-before-claim is off.' }
33 }
34 if (arg === 'on') {
35 isEnabled = true
36 return { text: 'verify-before-claim is on.' }
37 }
38 const turn: LastTurn | null = await read($, last)
39 const state = isEnabled ? 'on' : 'off'
40 if (turn === null) return { text: `verify-before-claim is ${state}. No turn completed yet.` }
41
42 return {
43 text: `verify-before-claim is ${state}. Last turn: code changed: ${yesNo(turn.codeChanged)}, checks run: ${turn.checks}, claim found: ${turn.claim === null ? 'no' : `yes ("${turn.claim}")`}.`,
44 }
45 })
46
47 on('prompt.submit', ($, e, next) => {
48 codeChanged = false
49 checks = 0
50
51 return next(e)
52 })
53
54 on('tool.call', async ($, e, next) => {
55 const ran = await next(e)
56 if (e.agentId !== undefined || ran.deny !== undefined) return ran
57 if (e.tool === 'Bash' && isCheckCommand(e.command)) checks += 1
58 if (ran.isError === true) return ran
59 const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
60 if (path !== undefined && isCodePath(path)) codeChanged = true
61
62 return ran
63 })
64
65 on('turn.complete', async ($, e, next) => {
66 if (e.agentId !== undefined || e.isAborted || !isEnabled) return next(e)
67 const claim = findClaim(e.answer)
68 await update($, last, () => ({ codeChanged, checks, claim }))
69 if (checks > 0) await update($, isWarning, () => false)
70 else if (codeChanged && claim !== null) {
71 await update($, isWarning, () => true)
72 $.ui.toast(MESSAGE)
73 }
74
75 return next(e)
76 })
77
78 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79 if (!(await read($, isWarning)) || e.props.hasSurvey) return next(e)
80 const { Box, Button, Text } = $.ui.resolve(e)
81
82 return (
83 <Box>
84 <Text color="yellow">⚠ {MESSAGE} </Text>
85 <Button key="dismiss" label="Dismiss" onPress={() => update($, isWarning, () => false)} />
86 </Box>
87 )
88 })
89}
90hooks/verify.ts 88 lines1// Pure logic: which Bash commands count as a check, and whether prose claims success.
2
3const WRAPPERS = /^(?:sudo|npx|bunx|time|env|nohup)\s+/
4const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/
5const CHECKS: RegExp[] = [
6 /^xcodebuild\b/,
7 /^swift\s+(?:build|test)\b/,
8 /^(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:build|test|lint)\b/,
9 /^(?:tsc|vitest|jest|pytest|swiftlint|eslint)\b/,
10 /^python3?\s+-m\s+pytest\b/,
11 /^cargo\s+(?:build|test|check|clippy)\b/,
12 /^go\s+(?:build|test|vet)\b/,
13 /^make\b/,
14 /^(?:gradle|\.\/gradlew)\b/,
15 /^claude\s+plugin\s+(?:test|validate)\b/,
16]
17
18function isCheckSegment(segment: string): boolean {
19 let rest = segment.trim()
20 let isProxied = false
21 for (;;) {
22 const stripped = rest.replace(/^rtk\s+/, () => ((isProxied = true), '')).replace(WRAPPERS, '').replace(ENV_ASSIGN, '')
23 if (stripped === rest) break
24 rest = stripped
25 }
26 if (isProxied && /^(?:err|test)\s+\S/.test(rest)) return true
27
28 return CHECKS.some(pattern => pattern.test(rest))
29}
30
31// Whether any segment of a shell command (split on && ; || and newlines) is a build, test or lint run.
32export function isCheckCommand(command: string): boolean {
33 return command.split(/&&|\|\||;|\n/).some(isCheckSegment)
34}
35
36// Whether a changed path is code: everything except Markdown and plain text.
37export const isCodePath = (path: string): boolean => !/\.(?:md|txt)$/i.test(path)
38
39const L = String.raw`(?<![\p{L}])`
40const R = String.raw`(?![\p{L}])`
41const CLAIMS = new RegExp(
42 L +
43 '(?:' +
44 [
45 'corrigée?s?',
46 'résolu(?:e|es|s)?',
47 'ça (?:marche|fonctionne)',
48 'ca (?:marche|fonctionne)',
49 'tests? (?:passe|passent|(?:sont|est) (?:au vert|verts?))',
50 'tous les tests passent',
51 'build réussi',
52 'tout passe',
53 'terminée?s?',
54 'c.est bon',
55 'fixed',
56 'works now',
57 'now works',
58 'it works',
59 'all tests (?:pass|are passing)',
60 'tests? (?:pass|passing)',
61 'build (?:succeeds|succeeded|is green)',
62 'builds successfully',
63 'done',
64 ].join('|') +
65 ')' +
66 R,
67 'iu',
68)
69// Sentences that deny, ask or only suppose are not claims.
70const HEDGES = /\b(?:pas|jamais|not|never|no|if|once|unless|si)\b|n['’]t\b|\bn['’]/iu
71
72function stripCode(text: string): string {
73 return text.replace(/```[\s\S]*?(?:```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ')
74}
75
76// The first success phrase found in the prose of an answer (code stripped), or null.
77export function findClaim(answer: string): string | null {
78 const sentences = stripCode(answer).split(/(?<=[.!?:;])\s+|\n+/)
79 for (const sentence of sentences) {
80 const s = sentence.trim()
81 if (s === '' || s.endsWith('?') || HEDGES.test(s)) continue
82 const match = CLAIMS.exec(s)
83 if (match !== null) return match[0].toLowerCase()
84 }
85
86 return null
87}
88types/index.d.ts 8 lines1export type LastTurn = { codeChanged: boolean; checks: number; claim: string | null }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'verify-before-claim': { isWarning: boolean; last: LastTurn | null }
6 }
7}
8