SLOPSHOPPER

verify-before-claim

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

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verify-before-claim
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ verify-before-claim │ ⏺ Read(src/auth.ts) │ verify-before-claim: success claimed │ ⎿ Read 6 lines │ without running a build or test this turn │ ⏺ 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 › /verify-before-claim ⎿ verify-before-claim: verify-before-claim is on. Last turn: code changed: yes, checks run: 0, claim found: yes ("done"). ⚠ verify-before-claim: success claimed without running a build or test this turn [ Dismiss ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚠ verify-before-claim: success claimed without running a build or test this turn [ Dismiss ]
README

verify-before-claim

"Verify, then speak": warns when a turn changed code, claimed success, and never ran a build, test or lint.

What it does

  • Tracks each main-conversation turn (subagents are ignored): whether code was changed, and whether a check command ran.
  • Code changed = a successful Edit, Write or NotebookEdit on a file that is not .md or .txt. Code changed through Bash is not seen.
  • Check ran = a 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.
  • When a turn completes with code changed, no check run, and a success claim in the answer, a toast 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.
  • The band clears on the next turn that runs a check, or on Dismiss.

Commands

CommandEffect
/verify-before-claim offStop checking (and clear the band).
/verify-before-claim onResume.
/verify-before-claim statusShow the last turn: code changed, checks run, claim found.

Install

claude --plugin-dir /path/to/ModsTools/mods/verify-before-claim

Limits

  • Claim detection is a phrase heuristic on prose only (fenced and inline code are ignored), in French and English: "corrigé", "ça marche", "les tests passent", "build réussi", "tout passe", "terminé", "c'est bon", "fixed", "works now", "all tests pass", "build succeeds", "done", and a few variants.
  • A sentence containing a negation (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.
  • It cannot tell whether the check covered the changed code, nor whether it passed.
  • Edits made through Bash (sed -i, redirects) do not count as code changed.
  • The on/off switch lasts for the session only.

Develop

claude plugin validate mods/verify-before-claim
claude plugin test mods/verify-before-claim   # 17 tests
Source 3 files
hooks/register.tsx 90 lines
1import { 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}
90
hooks/verify.ts 88 lines
1// 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}
88
types/index.d.ts 8 lines
1export 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