When a turn changed code and no test, check or build ran, you get a toast at once and Claude is reminded on your next prompt to say what was and was not…

Install: /plugin install verify-before-done --marketplace samuelbfernandes/moddeck
Enforces "say what was and was not verified". When a turn changes code and runs no test, check or build, you see it at once and Claude is reminded.
.R, .rs, .py, .jl, .ts, .js, .go, .sh, .sql...) and whether it ran a test, check or build (devtools::test(), R CMD check, cargo test/check/clippy/build, pytest, npm test/build, go test, make test, quarto render, tsc, claude plugin test...)./verify-before-done status: how many turns this session /verify-before-done on | off
It sees that a test command ran, not that it passed. Costs about 40 tokens once per missed turn.
verify-before-done → isOff, unverified (see types/index.d.ts).
hooks/register.tsx 90 lines1// verify-before-done: it watches each turn for code edits (Edit, Write, MultiEdit, NotebookEdit on a code file) and for
2// test, check or build commands (Bash). When a turn ends having changed code without running any, the person gets a
3// toast at once, and the model gets one reminder line with the person's next prompt, asking it to say what was and was
4// not verified. It never blocks and never runs anything. FAIL OPEN: any error leaves the turn and the prompt unchanged.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { MISSED_TOAST, NOTE, isCodeFile, isEditTool, isTestCommand } from './lib'
9
10type Dollar = EngineInterface
11
12const COMMAND = 'verify-before-done'
13
14const isOff = atom({ plugin: 'verify-before-done', key: 'isOff' } as const, false)
15const unverified = atom({ plugin: 'verify-before-done', key: 'unverified' } as const, 0)
16
17// This turn's facts and the reminder waiting for the next prompt (the module's own variables: they restart on a reload).
18let edited = false
19let tested = false
20let owed = false
21
22async function run($: Dollar, args: string): Promise<string> {
23 const word = args.trim().toLowerCase()
24 if (word === 'on' || word === 'off') {
25 await $.store.set('off', word === 'off')
26 await update($, isOff, () => word === 'off')
27 return `verify-before-done · ${word}`
28 }
29 if (word === '' || word === 'status') {
30 const n = await read($, unverified)
31 return `verify-before-done · ${(await read($, isOff)) ? 'off' : 'on'} · ${n} turn${n === 1 ? '' : 's'} changed code with no test run this session`
32 }
33 return 'verify-before-done · use /verify-before-done [on|off|status]; nothing changed'
34}
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 edited = tested = owed = false
39 try {
40 const off = (await $.store.get('off')) === true
41 await update($, isOff, () => off)
42 } catch {
43 // reads as on
44 }
45 try {
46 await $.command.register({ name: COMMAND, description: 'Flags a turn that changed code and ran no test: /verify-before-done [on|off|status]', argumentHint: '[on|off|status]', immediate: true })
47 } catch {
48 // fail-open: the mod still works without its command
49 }
50 return next(e)
51 })
52
53 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({ text: 'verify-before-done · that did not work, nothing changed' }))
54
55 on('turn.start', async (_$, e, next) => {
56 if (e.agentId === undefined) edited = tested = false
57 return next(e)
58 }).catch((_$, e, next) => next(e))
59
60 // Watches only: the call runs first and its result goes back unchanged.
61 on('tool.call', async ($, e, next) => {
62 const ran = await next(e)
63 if (ran.deny !== undefined || ran.isError === true || (await read($, isOff))) return ran
64 const input = e as unknown as Record<string, unknown>
65 const tool = String(e.tool)
66 if (tool === 'Bash' && typeof input.command === 'string' && isTestCommand(input.command)) tested = true
67 const target = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
68 if (isEditTool(tool) && isCodeFile(target)) edited = true
69 return ran
70 }).catch(($, e, next) => next(e))
71
72 on('turn.complete', async ($, e, next) => {
73 const out = await next(e)
74 if (e.agentId !== undefined || e.reason !== 'answer' || (await read($, isOff))) return out
75 if (edited && !tested) {
76 owed = true
77 await update($, unverified, n => n + 1)
78 $.ui.toast(MISSED_TOAST, { timeoutMs: 8000 })
79 }
80 edited = tested = false
81 return out
82 }).catch(($, e, next) => next(e))
83
84 on('prompt.submit', async ($, e, next) => {
85 if (!owed || (await read($, isOff))) return next(e)
86 owed = false
87 return next({ ...e, context: [...(e.context ?? []), NOTE] })
88 }).catch(($, e, next) => next(e))
89}
90hooks/lib.ts 16 lines1// Pure helpers for verify-before-done: what counts as code, and what counts as a test, check or build run.
2
3const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
4const CODE = /\.(r|rs|py|jl|cpp|c|h|ts|tsx|js|jsx|go|sh|sql)$/i
5
6export const isEditTool = (tool: string): boolean => EDIT_TOOLS.has(tool)
7export const isCodeFile = (path: string): boolean => CODE.test(path.replace(/\\/g, '/'))
8
9/** A command that runs tests, a check or a build of the project. */
10export const isTestCommand = (cmd: string): boolean =>
11 /\b(devtools::(test|check)|testthat|R\s+CMD\s+check|rcmdcheck|cargo\s+(test|nextest|clippy|check|build)|pytest|py\.test|npm\s+(run\s+)?(test|build)|pnpm\s+(run\s+)?(test|build)|vitest|jest|go\s+test|make\s+(test|check)|bun\s+test|claude\s+plugin\s+(test|validate)|quarto\s+render|tsc\b)/i.test(cmd)
12
13export const MISSED_TOAST = 'verify-before-done: code changed and no test or check ran this turn'
14export const NOTE =
15 'verify-before-done: your last turn changed code and ran no test, check or build. In your next reply say what was and was not verified; if you said it was done, run the tests now or retract it.'
16types/index.d.ts 11 lines1declare module 'claude-code' {
2 interface PluginState {
3 'verify-before-done': {
4 /** True while switched off (it ships on). */
5 isOff: boolean
6 /** Turns this session that changed code and ran no test, check or build. */
7 unverified: number
8 }
9 }
10}
11