SLOPSHOPPER

verify-gate

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

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

verify-gate

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.

Install

/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

Use it

  • Automatic once installed. Needs a recognisable project (package.json, pyproject.toml or Makefile).

What you'll see

Illustrative output.

All green

verified

Gives up

verify-gate: still failing after 3 retries, giving up this turn
Source 2 files
hooks/register.ts 92 lines
1import 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}
92
hooks/lib.ts 115 lines
1// 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