Runs the project fast checks after a turn that edited files and sends failures back to the model.

Runs your tests, lint and type-check after edits, and feeds failures back.
After a turn that edited code, detects your project's fast checks (npm/pnpm/yarn/bun scripts for test, lint, typecheck; pytest, ruff, mypy/pyright via uv; make test) and runs them. Failures go back to the model so it fixes them, up to 3 retries. Docs, images and config edits are ignored.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install verify-gate@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/verify-gate
Illustrative output.
All green
verified
Gives up
verify-gate: still failing after 3 retries, giving up this turnhooks/register.ts 92 lines1import type { Register, EngineInterface } from 'claude-code'
2import {
3 MAX_RETRIES, detectChecks, isCodeEdit, failureMessage, projectName, winsLine,
4 type CheckResult, type ProjectFiles,
5} from './lib.ts'
6
7type Engine = EngineInterface
8
9// Per-session counters; a hot reload resets them, which only gives a fresh budget.
10let hasEdits = false
11let retries = 0
12
13async function readIfThere($: Engine, path: string): Promise<string | undefined> {
14 if (!(await $.fs.exists(path).catch(() => false))) return undefined
15 const text = await $.fs.read(path).catch(() => undefined)
16 return typeof text === 'string' ? text : undefined
17}
18
19async function loadProject($: Engine, cwd: string): Promise<ProjectFiles> {
20 const entries = await $.fs.list(cwd).catch(() => [])
21 const names = entries.map(e => e.name)
22 const files: ProjectFiles = { names }
23 if (names.includes('package.json')) files.packageJson = await readIfThere($, `${cwd}/package.json`)
24 if (names.includes('pyproject.toml')) files.pyproject = await readIfThere($, `${cwd}/pyproject.toml`)
25 const make = names.find(n => n === 'Makefile' || n === 'makefile' || n === 'GNUmakefile')
26 if (make) files.makefile = await readIfThere($, `${cwd}/${make}`)
27 return files
28}
29
30async function appendWin($: Engine, line: string): Promise<void> {
31 const home = (await $.env.get('HOME')) ?? ''
32 const path = `${home}/.claude/state/wins.md`
33 const old = (await readIfThere($, path)) ?? ''
34 await $.fs.write(path, `${old}${old === '' || old.endsWith('\n') ? '' : '\n'}${line}\n`)
35}
36
37export const register: Register = on => {
38 for (const tool of ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] as const) {
39 on('tool.call', { tool }, ($, e, next) => {
40 const path = tool === 'NotebookEdit' ? (e as { notebook_path?: string }).notebook_path : (e as { file_path?: string }).file_path
41 if (isCodeEdit(path)) hasEdits = true
42 return next(e)
43 }).catch(($, e, next) => next(e))
44 }
45
46 on('turn.start', ($, e, next) => {
47 hasEdits = false
48 retries = 0
49 return next(e)
50 })
51
52 on('classic.Stop', async ($, e, next) => {
53 const base = await next(e)
54 if (!hasEdits) return base
55 const cwd = e.cwd
56 const checks = detectChecks(await loadProject($, cwd))
57 if (checks.length === 0) {
58 hasEdits = false
59 return base
60 }
61
62 const results: CheckResult[] = []
63 for (const check of checks) {
64 try {
65 const r = await $.process.run(check.argv, { cwd, timeoutMs: 180000 })
66 results.push({ check, exitCode: r.exitCode, output: `${r.stdout}${r.stderr}`.trim() })
67 } catch (err) {
68 results.push({ check, exitCode: 1, output: `could not run: ${String(err)}` })
69 }
70 }
71
72 if (results.every(r => r.exitCode === 0)) {
73 hasEdits = false
74 retries = 0
75 const passed = results.map(r => r.check.name)
76 $.ui.toast('verified')
77 const now = await $.clock.now()
78 await appendWin($, winsLine(new Date(now).toISOString(), projectName(cwd), passed)).catch(() => undefined)
79 return base
80 }
81
82 if (retries >= MAX_RETRIES) {
83 $.ui.toast(`verify-gate: still failing after ${MAX_RETRIES} retries, giving up this turn`)
84 hasEdits = false
85 retries = 0
86 return base
87 }
88 retries += 1
89 return { ...base, block: failureMessage(results, retries) }
90 }).catch(($, e, next) => next(e))
91}
92hooks/lib.ts 115 lines1// Pure detection and formatting logic for verify-gate.
2
3export const MAX_RETRIES = 3
4
5const NON_CODE = /\.(md|mdx|txt|html?|css|svg|png|jpe?g|gif|pdf|csv|ya?ml|toml|ini|lock|log)$/i
6
7/** True when editing this path can change what tests, lint or types see. */
8export function isCodeEdit(path: string | undefined): boolean {
9 return path === undefined || !NON_CODE.test(path)
10}
11
12export type Check = { name: string; argv: string[] }
13
14export type ProjectFiles = {
15 /** names of files present in the project root */
16 names: string[]
17 packageJson?: string
18 pyproject?: string
19 makefile?: string
20}
21
22export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
23
24export function detectPackageManager(names: string[]): PackageManager {
25 if (names.includes('pnpm-lock.yaml')) return 'pnpm'
26 if (names.includes('yarn.lock')) return 'yarn'
27 if (names.includes('bun.lock') || names.includes('bun.lockb')) return 'bun'
28 return 'npm'
29}
30
31function scriptArgv(pm: PackageManager, script: string): string[] {
32 if (script === 'test' && (pm === 'npm' || pm === 'pnpm' || pm === 'yarn')) return [pm, 'test']
33 return pm === 'yarn' ? ['yarn', script] : [pm, 'run', script]
34}
35
36const NODE_SCRIPTS: { key: string; aliases: string[] }[] = [
37 { key: 'test', aliases: ['test'] },
38 { key: 'lint', aliases: ['lint'] },
39 { key: 'typecheck', aliases: ['typecheck', 'type-check', 'check-types'] },
40]
41
42function detectNode(files: ProjectFiles): Check[] {
43 if (files.packageJson === undefined) return []
44 let scripts: Record<string, unknown> = {}
45 try {
46 const parsed = JSON.parse(files.packageJson) as { scripts?: Record<string, unknown> }
47 scripts = parsed.scripts ?? {}
48 } catch {
49 return []
50 }
51 const pm = detectPackageManager(files.names)
52 const out: Check[] = []
53 for (const { key, aliases } of NODE_SCRIPTS) {
54 const script = aliases.find(a => typeof scripts[a] === 'string')
55 if (script === undefined) continue
56 // the npm init placeholder is not a real test
57 if (key === 'test' && /no test specified/i.test(String(scripts[script]))) continue
58 out.push({ name: `${pm} ${script}`, argv: scriptArgv(pm, script) })
59 }
60 return out
61}
62
63function detectPython(files: ProjectFiles): Check[] {
64 const py = files.pyproject
65 if (py === undefined) return []
66 const out: Check[] = []
67 const mentions = (word: string): boolean => new RegExp(`\\b${word}\\b`, 'i').test(py)
68 if (/\[tool\.pytest/.test(py) || mentions('pytest') || files.names.includes('tests')) {
69 out.push({ name: 'pytest', argv: ['uv', 'run', 'pytest', '-q', '-x'] })
70 }
71 if (/\[tool\.ruff/.test(py) || mentions('ruff')) {
72 out.push({ name: 'ruff', argv: ['uv', 'run', 'ruff', 'check', '.'] })
73 }
74 if (/\[tool\.mypy/.test(py) || mentions('mypy')) {
75 out.push({ name: 'mypy', argv: ['uv', 'run', 'mypy', '.'] })
76 } else if (/\[tool\.pyright/.test(py) || mentions('pyright')) {
77 out.push({ name: 'pyright', argv: ['uv', 'run', 'pyright'] })
78 }
79 return out
80}
81
82function detectMake(files: ProjectFiles): Check[] {
83 return files.makefile !== undefined && /^test\s*:/m.test(files.makefile)
84 ? [{ name: 'make test', argv: ['make', 'test'] }]
85 : []
86}
87
88/** The fast checks this project defines; empty when none are found. */
89export function detectChecks(files: ProjectFiles): Check[] {
90 return [...detectNode(files), ...detectPython(files), ...detectMake(files)]
91}
92
93export type CheckResult = { check: Check; exitCode: number; output: string }
94
95/** Keeps the end of long output, where the failures are. */
96export function tailOutput(text: string, max = 6000): string {
97 return text.length <= max ? text : `[... ${text.length - max} characters cut ...]\n${text.slice(text.length - max)}`
98}
99
100export function failureMessage(results: CheckResult[], attempt: number): string {
101 const failed = results.filter(r => r.exitCode !== 0)
102 const body = failed
103 .map(r => `$ ${r.check.argv.join(' ')} (exit ${r.exitCode})\n${tailOutput(r.output)}`)
104 .join('\n\n')
105 return `verify-gate: ${failed.length} check(s) failed after your edits (attempt ${attempt} of ${MAX_RETRIES}). Fix the problems below, then finish again.\n\n${body}`
106}
107
108export function winsLine(dateIso: string, projectDir: string, passed: string[]): string {
109 return `${dateIso.slice(0, 10)} | ${projectDir} | ${passed.join(', ')}`
110}
111
112export function projectName(cwd: string): string {
113 return cwd.replace(/\/+$/, '').split('/').pop() || cwd
114}
115