Blocks Claude from ending a turn while code under guarded paths differs from the working tree that last passed the repository's check command. Proof is a git…

A Claude Code mod. It stops Claude from ending a turn while changed code has not passed the repository's check command.
done-gate: 2 file(s) changed since the last passing check (src/greet.py, tests/test_greet.py).
Run `bash scripts/check.sh` and fix any failure before you finish.
checks ends without error, the mod records a git tree id of the whole working tree: tracked and untracked files, ignored files left out. An edit made later through Edit, Write, sed -i or a subagent changes that id.paths, it blocks the turn once and names the check. With resolve, the repository's own command decides instead (below).Each repository opts in with .claude/done-gate.json. Without that file the mod does nothing.
{
"checks": ["^bash scripts/verify\\.sh"],
"paths": ["src/", "crates/"],
"hint": "bash scripts/verify.sh <lane>"
}
checks: regular expressions matched against the Bash command. Required.paths: path prefixes from the repository root that need proof. Empty or absent means every file.hint: the command named in the block message. Defaults to the first pattern.resolve: optional. A command and its arguments, run from the repository root, that decides which checks are unmet. It reads JSON on stdin, { "changed": [files changed since session start], "passes": [{ "command", "changed": [files changed since that command passed] }] }, and prints one unmet check per line (nothing when all are met). With resolve, paths and hint are unused. Use it when the repository already owns a map from paths to checks, so the map has one owner.Limits: a check passes when the tests it runs pass, so deleting a failing test passes too. The tree id is written through a private index at .git/done-gate.index; git add --all there writes blob objects to .git/objects, which git gc removes when unreferenced.
Needs function hooks (early access): Claude Code 2.1.287 or later. Test with claude plugin test plugins/done-gate.
hooks/done-gate.ts 119 lines1/**
2 * done-gate — Claude Mod (EARLY ACCESS)
3 *
4 * Blocks the main loop from ending a turn while changed files have not passed
5 * the checks they need.
6 *
7 * - Proof is a git tree id of the whole working tree (tracked and untracked,
8 * ignored files left out), written through a private index under .git.
9 * Edits by Edit, Write, Bash or a subagent all change it.
10 * - `tool.call` on Bash: a command matching `checks` that ends without error
11 * records the tree it passed on.
12 * - `classic.Stop`: with `resolve`, the repository's command reads the
13 * changed files and passes on stdin and prints each unmet check; without
14 * it, any file under `paths` changed since the last pass is unmet. Blocks
15 * once; the retry (`stop_hook_active`) ends the turn with a toast.
16 * - Inactive in a repository without `.claude/done-gate.json`.
17 */
18import type { EngineInterface, Register } from 'claude-code'
19import { CONFIG_PATH, configFault, decide, decideResolved, isCheck, parseConfig } from './gate.ts'
20import type { Config, Pass, Verdict } from './gate.ts'
21
22type Repo = { root: string; config: Config } | { root: string; fault: string }
23
24let startTree: string | undefined
25let lastPassTree: string | undefined
26const passTrees = new Map<string, string>()
27let queue: Promise<unknown> = Promise.resolve()
28
29const serial = <T>(work: () => Promise<T>): Promise<T> => {
30 const run = queue.then(work, work)
31 queue = run.catch(() => undefined)
32 return run
33}
34
35async function git($: EngineInterface, cwd: string, args: string[], env?: Record<string, string>) {
36 const { exitCode, stdout, stderr } = await $.process.run(['git', ...args], { cwd, env })
37 if (exitCode !== 0) throw new Error(`git ${args[0]}: ${stderr.trim()}`)
38 return stdout.trim()
39}
40
41async function repoOf($: EngineInterface): Promise<Repo | undefined> {
42 const cwd = await $.session.cwd()
43 const root = await git($, cwd, ['rev-parse', '--show-toplevel']).catch(() => undefined)
44 if (root === undefined) return undefined
45 const path = `${root}/${CONFIG_PATH}`
46 if (!(await $.fs.exists(path))) return undefined
47 const parsed = parseConfig(await $.fs.read(path))
48 return 'error' in parsed ? { root, fault: parsed.error } : { root, config: parsed }
49}
50
51const treeOf = ($: EngineInterface, root: string) =>
52 serial(async () => {
53 const index = await git($, root, ['rev-parse', '--path-format=absolute', '--git-path', 'done-gate.index'])
54 const env = { GIT_INDEX_FILE: index }
55 await git($, root, ['add', '--all'], env)
56 return git($, root, ['write-tree'], env)
57 })
58
59const changedBetween = async ($: EngineInterface, root: string, from: string, to: string) =>
60 from === to ? [] : (await git($, root, ['diff', '--name-only', from, to])).split('\n').filter(Boolean)
61
62async function unmetChecks($: EngineInterface, root: string, resolve: string[], start: string, now: string) {
63 const passes: Pass[] = []
64 for (const [command, tree] of passTrees) {
65 passes.push({ command, changed: await changedBetween($, root, tree, now) })
66 }
67 const stdin = JSON.stringify({ changed: await changedBetween($, root, start, now), passes })
68 const { exitCode, stdout, stderr } = await $.process.run(resolve, { cwd: root, stdin })
69 if (exitCode !== 0) throw new Error(`resolve exited ${exitCode}: ${stderr.trim()}`)
70 return stdout.split('\n').map(line => line.trim()).filter(Boolean)
71}
72
73async function verdictAtStop($: EngineInterface, isRetry: boolean): Promise<Verdict> {
74 const repo = await repoOf($)
75 if (repo === undefined) return { kind: 'pass' }
76 if ('fault' in repo) return isRetry ? { kind: 'pass' } : configFault(repo.fault)
77 const now = await treeOf($, repo.root)
78 startTree ??= now
79 if (repo.config.resolve !== undefined) {
80 const unmet = await unmetChecks($, repo.root, repo.config.resolve, startTree, now)
81 return decideResolved({ unmet, isRetry })
82 }
83 const unproven = await changedBetween($, repo.root, lastPassTree ?? startTree, now)
84 return decide({ config: repo.config, unproven, isRetry })
85}
86
87export const register: Register = on => {
88 on('session.start', async ($, e, next) => {
89 const repo = await repoOf($).catch(() => undefined)
90 if (repo !== undefined) startTree = await treeOf($, repo.root).catch(() => undefined)
91 return next(e)
92 })
93
94 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
95 const ran = await next(e)
96 if (ran.deny !== undefined || ran.isError === true) return ran
97 const repo = await repoOf($).catch(() => undefined)
98 if (repo !== undefined && 'config' in repo && isCheck(repo.config, e.command)) {
99 const tree = await treeOf($, repo.root).catch(() => undefined)
100 if (tree !== undefined) {
101 lastPassTree = tree
102 passTrees.set(e.command.trim(), tree)
103 }
104 }
105 return ran
106 })
107
108 on('classic.Stop', async ($, e, next) => {
109 const verdict = await verdictAtStop($, e.stop_hook_active)
110 if (verdict.kind === 'block') return { block: verdict.reason }
111 if (verdict.kind === 'release') $.ui.toast(`done-gate: ${verdict.reason}`)
112 return next(e)
113 }).catch(($, e, next) =>
114 e.stop_hook_active || next.called
115 ? next(e)
116 : { block: 'done-gate: its check failed to run. Say in your reply that the work is unverified.' },
117 )
118}
119hooks/gate.ts 95 lines1export type Config = {
2 checks: RegExp[]
3 paths: string[]
4 hint: string
5 resolve?: string[]
6}
7
8export type ConfigError = { error: string }
9
10export type Verdict =
11 | { kind: 'pass' }
12 | { kind: 'block'; reason: string }
13 | { kind: 'release'; reason: string }
14
15export type Pass = { command: string; changed: string[] }
16
17export const CONFIG_PATH = '.claude/done-gate.json'
18
19const isStrings = (value: unknown): value is string[] =>
20 Array.isArray(value) && value.every(item => typeof item === 'string')
21
22export function parseConfig(text: string): Config | ConfigError {
23 let raw: unknown
24 try {
25 raw = JSON.parse(text)
26 } catch {
27 return { error: 'is not valid JSON' }
28 }
29 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
30 return { error: 'must be a JSON object' }
31 }
32 const { checks, paths = [], hint, resolve } = raw as Record<string, unknown>
33 if (!isStrings(checks) || checks.length === 0) {
34 return { error: '"checks" must be a non-empty list of regular expressions' }
35 }
36 if (!isStrings(paths)) return { error: '"paths" must be a list of path prefixes' }
37 if (hint !== undefined && typeof hint !== 'string') return { error: '"hint" must be a string' }
38 if (resolve !== undefined && (!isStrings(resolve) || resolve.length === 0)) {
39 return { error: '"resolve" must be a non-empty command and argument list' }
40 }
41 let compiled: RegExp[]
42 try {
43 compiled = checks.map(pattern => new RegExp(pattern))
44 } catch {
45 return { error: '"checks" holds an invalid regular expression' }
46 }
47 return { checks: compiled, paths, hint: hint ?? checks[0], resolve }
48}
49
50export const isCheck = (config: Config, command: string): boolean =>
51 config.checks.some(pattern => pattern.test(command))
52
53const guardedFiles = (config: Config, changed: readonly string[]): string[] =>
54 config.paths.length === 0
55 ? [...changed]
56 : changed.filter(file => config.paths.some(prefix => file.startsWith(prefix)))
57
58const listed = (items: readonly string[]) =>
59 items.slice(0, 3).join(', ') + (items.length > 3 ? `, +${items.length - 3} more` : '')
60
61function verdict(unmet: readonly string[], isRetry: boolean, block: string): Verdict {
62 if (unmet.length === 0) return { kind: 'pass' }
63 if (isRetry) return { kind: 'release', reason: `finished without a passing check (${listed(unmet)})` }
64 return {
65 kind: 'block',
66 reason:
67 `${block} Run each command as given, with no pipe, redirect or chained command, so its exit code is the check's. ` +
68 'If the check cannot run here, say why in your reply.',
69 }
70}
71
72export function decide(input: { config: Config; unproven: readonly string[]; isRetry: boolean }): Verdict {
73 const files = guardedFiles(input.config, input.unproven)
74 return verdict(
75 files,
76 input.isRetry,
77 `done-gate: ${files.length} file(s) changed since the last passing check (${listed(files)}). ` +
78 `Run \`${input.config.hint}\` and fix any failure before you finish.`,
79 )
80}
81
82export function decideResolved(input: { unmet: readonly string[]; isRetry: boolean }): Verdict {
83 return verdict(
84 input.unmet,
85 input.isRetry,
86 `done-gate: these checks have not passed on the current files: ${input.unmet.join('; ')}. ` +
87 'Run them and fix any failure before you finish.',
88 )
89}
90
91export const configFault = (error: string): Verdict => ({
92 kind: 'block',
93 reason: `done-gate: ${CONFIG_PATH} ${error}. Fix it or remove it before you finish.`,
94})
95