Claude cannot say "done" while the tests fail: a Stop gate runs your test command after every turn that changed files, and existing test files are locked so…

A Claude Code mod that does not let Claude say "done" while your tests fail, and does not let it edit the tests to make them pass.
● Edit(src/sum.test.ts)
⎿ Error: Tests are locked. If a test is wrong, say which one and why; the user can run /unlock-tests. (Blocked: edit of src/sum.test.ts)
Stop hook feedback:
done-gate: the checks fail, so the work is not done. `npm test` ended with exit 1 after 0.1s.
Last 40 lines of output:
> node sum.test.js
FAIL sum(1, 2): expected 3, got -1
Fix the code, not the tests, before saying it's done. If a test itself is wrong, stop and say which one and why.
done-gate: checks failed (exit 1): 2 rounds left
done-gate: checks passed in 0.1s
Claude reports work as finished when it isn't: it says the tests pass without running them, rewrites or hardcodes tests until they go green, and leaves stubs behind.
Existing tools: nizos/tdd-guard enforces the TDD cycle only, and the built-in /goal sets what Claude works toward. done-gate does two plain things: it runs your tests whenever Claude wants to stop after changing files, and it keeps the existing tests out of Claude's reach.
| Part | What |
|---|---|
| Test lock | Denies Edit, MultiEdit, Write and NotebookEdit on existing test files, and Bash rm, git rm, mv, redirects, tee, cp, sed -i and the like on them. Creating a new test file is allowed, and a test file created in this session stays editable, so TDD still works |
| Done gate | When Claude stops after a turn that changed files, it runs the check command. On failure Claude gets the exit code and the last 40 lines of output and keeps working, up to 3 rounds per prompt |
| Toasts | checks passed in 12s, checks failed (exit 1): 2 rounds left, checks still failing after 3 rounds |
/done-gate | Lock state, test globs, the resolved check command, whether files changed since the last check, and the last run: time, exit code, duration and output tail |
/unlock-tests, /lock-tests | Lift or restore the lock for the rest of the session |
The check command comes from the checkCommand setting, or from the project root:
| Found | Command |
|---|---|
package.json with a test script | npm test; pnpm test, yarn test or bun run test when that lockfile is there |
pyproject.toml, pytest.ini or setup.cfg | pytest -q, or uv run pytest -q with uv.lock |
Cargo.toml | cargo test |
go.mod | go test ./... |
| none of these | the gate does nothing and says so once per session |
The npm init placeholder script (echo "Error: no test specified" && exit 1) counts as no script, and pytest's "no tests collected" (exit 5) counts as a pass.
You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
From the marketplace in this repo:
/plugin marketplace add Jvrd97/claude-done-gate
/plugin install done-gate@done-gate
Or straight from disk, for one session:
git clone https://github.com/Jvrd97/claude-done-gate.git
claude --plugin-dir ./claude-done-gate
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
checkCommand | empty | Shell command to run, e.g. npm test && npm run typecheck. Empty: detect from the project root |
timeoutSeconds | 300 | A run that takes longer counts as failed. At most 600 |
maxRounds | 3 | How many times per prompt the gate sends Claude back before it lets it stop anyway |
lockTests | true | Lock existing test files |
testGlobs | empty | Comma-separated globs relative to the project root; empty means **/*.test.*, **/*.spec.*, **/test_*.py, **/*_test.py, **/*_test.go, /tests/, /__tests__/, /spec/ |
gate | on | off keeps only the test lock |
tool.call sees every tool call before it runs. For an editing tool it resolves the path against the project root and matches it with the test globs; an existing test file is denied. For Bash it reads the command: quotes, &&, pipes, cd, redirects, here-documents and bash -c are understood, and every path the command removes or writes is checked the same way.rm, mv, cp, sed -i, git checkout, npm install, python -c and so on) and that Claude Code did not itself classify as read-only. ls, cat, git status or npm test do not count.classic.Stop fires when Claude wants to end its turn. With changes marked, the mod runs the check command with sh -c in the project root and answers { block } on failure, which sends Claude back with the output. Rounds are counted per prompt (prompt_id); a continuation (stop_hook_active) never resets the count, so the gate cannot loop forever. A block from another Stop hook is passed on and costs no round./unlock-tests only works when you type it (at the prompt, over Remote Control or as the prompt of claude -p), not when it arrives from a channel, a peer session or the model.$.state): the lock override, the test files created this session, the change mark, the round count and the last run. The mod writes no files, sends nothing over the network and keeps nothing between sessions.What it can't do:
python fix.py, make, find -delete or xargs rm may touch test files without the lock noticing, and such commands do not mark the turn as changed either.test script in package.json, a conftest.py outside the globs, or the test runner config. Add those paths to testGlobs if you need them locked.rm -rf src is not denied even when src holds __tests__: the mod matches paths, it does not list folders.checkCommand at a fast subset.*.spec.* that is not a test is locked too, until you /unlock-tests or narrow testGlobs.sh, so on Windows it needs a POSIX shell on PATH.Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:
tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Мод не даёт Claude сказать «готово», пока тесты падают, и не даёт ему переписать тесты, чтобы они позеленели.
/unlock-tests снимает блокировку до конца сессии, /done-gate показывает состояние и последний прогон.Мод ничего не отправляет в сеть и не пишет файлы.
MIT
hooks/register.ts 314 lines1import { atom, read, update } from 'claude-code'
2import type { ClassicEventOf, CommandRunInput, EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { DoneGateRun } from '../types'
5import {
6 blockReason,
7 denyReason,
8 detectCommand,
9 failToast,
10 giveUpToast,
11 hasWildcard,
12 isPassing,
13 isTestPath,
14 joinPath,
15 matchersOf,
16 NO_COMMAND_TOAST,
17 passToast,
18 PROBE_FILES,
19 relativeTo,
20 settingsOf,
21 shellEffects,
22 statusReport,
23 tailOf,
24} from './logic'
25import type { CheckCommand, LockState, ProbeFile, Settings } from './logic'
26
27const STATUS_COMMAND = 'done-gate'
28const UNLOCK_COMMAND = 'unlock-tests'
29const LOCK_COMMAND = 'lock-tests'
30const TOAST_MS = 6_000
31const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
32/** Who may unlock: the person at the prompt, the Remote Control bridge, or a `claude -p` run. Never the model or a relay. */
33const UNLOCKING_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
34
35const lockOverride = atom({ plugin: 'done-gate', key: 'lockOverride' } as const, null)
36const created = atom({ plugin: 'done-gate', key: 'created' } as const, [])
37const hasChanges = atom({ plugin: 'done-gate', key: 'hasChanges' } as const, false)
38const rounds = atom({ plugin: 'done-gate', key: 'rounds' } as const, { promptId: null, count: 0 })
39const lastRun = atom({ plugin: 'done-gate', key: 'lastRun' } as const, null)
40const hasSaidNoCommand = atom({ plugin: 'done-gate', key: 'hasSaidNoCommand' } as const, false)
41
42type StopInput = ClassicEventOf['classic.Stop']
43
44/** The verdict on one tool call: refuse it, let it create a new test file, or let it be. */
45type Guard = { kind: 'deny'; reason: string } | { kind: 'new-test'; path: string } | { kind: 'pass' }
46
47const PASS: Guard = { kind: 'pass' }
48
49/** The file an editing tool touches, read loosely: MultiEdit is not a built-in of every build. */
50const editTargetOf = (e: ToolCallInput): string | null => {
51 if (!EDIT_TOOLS.has(e.tool)) {
52 return null
53 }
54
55 const args = e as Readonly<Record<string, unknown>>
56 const path = args.file_path ?? args.notebook_path
57
58 return typeof path === 'string' ? path : null
59}
60
61async function lockOf($: EngineInterface, settings: Settings): Promise<LockState> {
62 const override = await read($, lockOverride)
63
64 return override === null ? { isLocked: settings.lockTests, isOverridden: false } : { isLocked: override, isOverridden: true }
65}
66
67/** A path the guard cannot stat counts as existing: the lock errs on the side of refusing. */
68async function existsOrUnknown($: EngineInterface, path: string): Promise<boolean> {
69 if (hasWildcard(path)) {
70 return true
71 }
72
73 return $.fs.exists(path).catch(() => true)
74}
75
76async function guardEdit($: EngineInterface, path: string, root: string, settings: Settings): Promise<Guard> {
77 const absolute = joinPath(root, path)
78 const relative = relativeTo(root, absolute)
79
80 if (!isTestPath(relative, matchersOf(settings.globs)) || (await read($, created)).includes(absolute)) {
81 return PASS
82 }
83
84 return (await existsOrUnknown($, absolute)) ? { kind: 'deny', reason: denyReason('edit of', relative) } : { kind: 'new-test', path: absolute }
85}
86
87async function guardShell($: EngineInterface, command: string, root: string, settings: Settings): Promise<Guard> {
88 const matchers = matchersOf(settings.globs)
89 const own = await read($, created)
90
91 for (const target of shellEffects(command, root).targets) {
92 const relative = relativeTo(root, target.path)
93
94 if (!isTestPath(relative, matchers) || own.includes(target.path)) {
95 continue
96 }
97
98 if (target.kind === 'remove') {
99 return { kind: 'deny', reason: denyReason('removing or moving', relative) }
100 }
101
102 if (await existsOrUnknown($, target.path)) {
103 return { kind: 'deny', reason: denyReason('shell write into', relative) }
104 }
105 }
106
107 return PASS
108}
109
110async function guard($: EngineInterface, e: ToolCallInput, settings: Settings): Promise<Guard> {
111 if (!(await lockOf($, settings)).isLocked) {
112 return PASS
113 }
114
115 const path = editTargetOf(e)
116
117 if (path !== null) {
118 return guardEdit($, path, await $.session.root(), settings)
119 }
120
121 return e.tool === 'Bash' ? guardShell($, e.command, await $.session.root(), settings) : PASS
122}
123
124/** Marks the turn as having changed files once a call that may have changed them went through. */
125async function noteChange($: EngineInterface, e: ToolCallInput, result: ToolCallResult, verdict: Guard): Promise<void> {
126 if (result.deny !== undefined) {
127 return
128 }
129
130 if (verdict.kind === 'new-test' && result.isError !== true) {
131 await update($, created, list => [...list, verdict.path])
132 }
133
134 if (editTargetOf(e) !== null) {
135 if (result.isError !== true) {
136 await update($, hasChanges, () => true)
137 }
138
139 return
140 }
141
142 // A failing command may still have written before it failed, so an error does not clear it.
143 if (e.tool === 'Bash' && result.isReadOnly !== true && shellEffects(e.command, await $.session.root()).isWrite) {
144 await update($, hasChanges, () => true)
145 }
146}
147
148async function probeProject($: EngineInterface, root: string): Promise<{ present: Set<ProbeFile>; packageJson: string | null }> {
149 const present = new Set<ProbeFile>()
150
151 for (const name of PROBE_FILES) {
152 if (await $.fs.exists(joinPath(root, name)).catch(() => false)) {
153 present.add(name)
154 }
155 }
156
157 const packageJson = present.has('package.json') ? await $.fs.read(joinPath(root, 'package.json')).catch(() => null) : null
158
159 return { present, packageJson }
160}
161
162async function resolveCheck($: EngineInterface, root: string, settings: Settings): Promise<CheckCommand | null> {
163 if (settings.checkCommand !== '') {
164 return { command: settings.checkCommand, source: 'setting' }
165 }
166
167 return detectCommand(await probeProject($, root))
168}
169
170async function runCheck($: EngineInterface, check: CheckCommand, root: string, settings: Settings): Promise<DoneGateRun> {
171 const startedAt = await $.clock.now()
172 const outcome = await $.process
173 .run(['sh', '-c', check.command], { cwd: root, timeoutMs: settings.timeoutMs })
174 .then(ran => ({ exitCode: ran.exitCode, tail: tailOf(ran.stdout, ran.stderr) }))
175 .catch((error: unknown) => ({
176 exitCode: null,
177 tail: `done-gate could not finish \`${check.command}\` within ${settings.timeoutMs / 1000}s: ${error instanceof Error ? error.message : String(error)}`,
178 }))
179 const at = await $.clock.now()
180
181 return { at, command: check.command, exitCode: outcome.exitCode, durationMs: at - startedAt, isPassed: isPassing(outcome.exitCode, check), tail: outcome.tail }
182}
183
184/** Ends a stop sequence: the model may stop, and the next one starts counting rounds from zero. */
185async function release($: EngineInterface, promptId: string | null): Promise<null> {
186 await update($, hasChanges, () => false)
187 await update($, rounds, () => ({ promptId, count: 0 }))
188
189 return null
190}
191
192/** Runs the checks when the model wants to stop after changing files; answers the block reason, or null to let it stop. */
193async function gate($: EngineInterface, e: StopInput, settings: Settings): Promise<string | null> {
194 const promptId = e.prompt_id ?? null
195 const counted = await read($, rounds)
196
197 // A new prompt starts a new sequence; a continuation (stop_hook_active) never does, so the cap always holds.
198 if (!e.stop_hook_active && counted.promptId !== promptId) {
199 await update($, rounds, () => ({ promptId, count: 0 }))
200 }
201
202 if (!settings.isGateOn || !(await read($, hasChanges))) {
203 return null
204 }
205
206 const root = await $.session.root()
207 const check = await resolveCheck($, root, settings)
208
209 if (check === null) {
210 if (!(await read($, hasSaidNoCommand))) {
211 await update($, hasSaidNoCommand, () => true)
212 $.ui.toast(NO_COMMAND_TOAST, { timeoutMs: TOAST_MS })
213 }
214
215 return release($, promptId)
216 }
217
218 const run = await runCheck($, check, root, settings)
219 await update($, lastRun, () => run)
220
221 if (run.isPassed) {
222 $.ui.toast(passToast(run), { timeoutMs: TOAST_MS })
223
224 return release($, promptId)
225 }
226
227 const used = (await read($, rounds)).count
228
229 if (used >= settings.maxRounds) {
230 $.ui.toast(giveUpToast(settings.maxRounds), { timeoutMs: TOAST_MS })
231
232 return release($, promptId)
233 }
234
235 await update($, rounds, () => ({ promptId, count: used + 1 }))
236 $.ui.toast(failToast(run, settings.maxRounds - used - 1), { timeoutMs: TOAST_MS })
237
238 return blockReason(run)
239}
240
241async function report($: EngineInterface, settings: Settings): Promise<string> {
242 const root = await $.session.root()
243
244 return statusReport({
245 settings,
246 lock: await lockOf($, settings),
247 check: await resolveCheck($, root, settings),
248 root,
249 hasChanges: await read($, hasChanges),
250 lastRun: await read($, lastRun),
251 now: await $.clock.now(),
252 })
253}
254
255async function setLock($: EngineInterface, e: CommandRunInput, isLocked: boolean): Promise<{ text: string }> {
256 if (!isLocked && !UNLOCKING_ORIGINS.has(e.origin.kind)) {
257 return { text: '/unlock-tests runs only when you type it; it stays locked.' }
258 }
259
260 await update($, lockOverride, () => isLocked)
261
262 return {
263 text: isLocked
264 ? 'done-gate: tests are locked for this session.'
265 : 'done-gate: tests are unlocked for this session. /lock-tests locks them again.',
266 }
267}
268
269async function registerCommands($: EngineInterface): Promise<void> {
270 await $.command.register({ name: STATUS_COMMAND, description: 'Show the test lock, the check command and the last check run' })
271 await $.command.register({ name: UNLOCK_COMMAND, description: 'Let Claude edit existing test files for the rest of this session' })
272 await $.command.register({ name: LOCK_COMMAND, description: 'Lock existing test files again for this session' })
273}
274
275export const register: Register = (on, options) => {
276 const settings = settingsOf(options)
277
278 on('session.start', async ($, e, next) => {
279 await registerCommands($)
280
281 return next(e)
282 })
283
284 on('tool.call', async ($, e, next) => {
285 const verdict = await guard($, e, settings)
286
287 if (verdict.kind === 'deny') {
288 return { deny: verdict.reason }
289 }
290
291 const result = await next(e)
292 await noteChange($, e, result, verdict)
293
294 return result
295 })
296
297 on('classic.Stop', async ($, e, next) => {
298 const below = await next(e)
299
300 // Another hook already sends the model back; one reason at a time, and no round spent.
301 if (below.block !== undefined) {
302 return below
303 }
304
305 const block = await gate($, e, settings)
306
307 return block === null ? below : { ...below, block }
308 })
309
310 on('command.run', { command: STATUS_COMMAND }, async $ => ({ text: await report($, settings) }))
311 on('command.run', { command: UNLOCK_COMMAND }, async ($, e) => setLock($, e, false))
312 on('command.run', { command: LOCK_COMMAND }, async ($, e) => setLock($, e, true))
313}
314hooks/logic.ts 883 lines1import type { DoneGateRun } from '../types'
2
3// ---------------------------------------------------------------- settings
4
5export const DEFAULT_TEST_GLOBS: readonly string[] = [
6 '**/*.test.*',
7 '**/*.spec.*',
8 '**/test_*.py',
9 '**/*_test.py',
10 '**/*_test.go',
11 '**/tests/**',
12 '**/__tests__/**',
13 '**/spec/**',
14]
15
16const DEFAULT_TIMEOUT_SECONDS = 300
17/** `$.process.run` refuses to wait longer than ten minutes. */
18const MAX_TIMEOUT_SECONDS = 600
19const DEFAULT_MAX_ROUNDS = 3
20const MAX_ROUNDS_CAP = 20
21const MS_PER_SECOND = 1000
22
23export type Settings = {
24 checkCommand: string
25 timeoutMs: number
26 maxRounds: number
27 lockTests: boolean
28 globs: readonly string[]
29 isGateOn: boolean
30}
31
32const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value))
33
34const numberOr = (value: unknown, fallback: number): number => {
35 const parsed = typeof value === 'number' ? value : Number(value)
36
37 return Number.isFinite(parsed) ? parsed : fallback
38}
39
40/** Splits on commas outside `{...}`, so `*.{ts,js}` stays one glob. */
41const splitTopLevel = (text: string): string[] => {
42 const parts: string[] = []
43 let depth = 0
44 let current = ''
45
46 for (const char of text) {
47 if (char === ',' && depth === 0) {
48 parts.push(current)
49 current = ''
50 continue
51 }
52
53 depth += char === '{' ? 1 : char === '}' && depth > 0 ? -1 : 0
54 current += char
55 }
56
57 return [...parts, current]
58}
59
60/** Test globs as the settings field spells them: comma-separated, empty means the defaults. */
61export const parseGlobs = (text: string): readonly string[] => {
62 const globs = splitTopLevel(text)
63 .map(part => part.trim())
64 .filter(part => part !== '')
65
66 return globs.length === 0 ? DEFAULT_TEST_GLOBS : globs
67}
68
69export const settingsOf = (options: Readonly<Record<string, unknown>>): Settings => ({
70 checkCommand: String(options.checkCommand ?? '').trim(),
71 timeoutMs: clamp(numberOr(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), 1, MAX_TIMEOUT_SECONDS) * MS_PER_SECOND,
72 maxRounds: Math.floor(clamp(numberOr(options.maxRounds, DEFAULT_MAX_ROUNDS), 0, MAX_ROUNDS_CAP)),
73 lockTests: options.lockTests !== false && options.lockTests !== 'false',
74 globs: parseGlobs(String(options.testGlobs ?? '')),
75 isGateOn: String(options.gate ?? 'on') !== 'off',
76})
77
78// ---------------------------------------------------------------- paths
79
80/** Forward slashes, `.` and `..` folded; a leading `/` kept, a leading `..` of a relative path kept. */
81export const normalizePath = (path: string): string => {
82 const slashed = path.replace(/\\/g, '/')
83 const isAbsolute = slashed.startsWith('/')
84 const parts: string[] = []
85
86 for (const part of slashed.split('/')) {
87 if (part === '' || part === '.') {
88 continue
89 }
90
91 if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..') {
92 parts.pop()
93 continue
94 }
95
96 if (part === '..' && isAbsolute) {
97 continue
98 }
99
100 parts.push(part)
101 }
102
103 const joined = parts.join('/')
104
105 return isAbsolute ? `/${joined}` : joined
106}
107
108/** A spelling the shell expands (`~`, `$HOME`) cannot be placed; it is matched as written. */
109const isUnplaceable = (path: string): boolean => path.startsWith('~') || path.includes('$')
110
111export const joinPath = (base: string, path: string): string =>
112 path.startsWith('/') || isUnplaceable(path) ? normalizePath(path) : normalizePath(`${base}/${path}`)
113
114/** The path relative to the root when it is under it; else the path without its leading slash. */
115export const relativeTo = (root: string, path: string): string => {
116 const normalized = normalizePath(path)
117 const base = normalizePath(root).replace(/\/$/, '')
118
119 if (normalized === base) {
120 return ''
121 }
122
123 return normalized.startsWith(`${base}/`) ? normalized.slice(base.length + 1) : normalized.replace(/^\/+/, '')
124}
125
126// ---------------------------------------------------------------- globs
127
128const escapeRegExp = (text: string): string => text.replace(/[.+^$()|[\]\\{}*?]/g, '\\$&')
129
130const globBody = (glob: string): string => {
131 let out = ''
132 let index = 0
133
134 while (index < glob.length) {
135 if (glob.startsWith('**/', index)) {
136 out += '(?:.*/)?'
137 index += 3
138 continue
139 }
140
141 if (glob.startsWith('/**', index) && index + 3 === glob.length) {
142 out += '(?:/.*)?'
143 index += 3
144 continue
145 }
146
147 if (glob.startsWith('**', index)) {
148 out += '.*'
149 index += 2
150 continue
151 }
152
153 const char = glob.charAt(index)
154
155 if (char === '*') {
156 out += '[^/]*'
157 index += 1
158 continue
159 }
160
161 if (char === '?') {
162 out += '[^/]'
163 index += 1
164 continue
165 }
166
167 const close = char === '{' ? glob.indexOf('}', index) : -1
168
169 if (close > index) {
170 out += `(?:${glob.slice(index + 1, close).split(',').map(globBody).join('|')})`
171 index = close + 1
172 continue
173 }
174
175 out += escapeRegExp(char)
176 index += 1
177 }
178
179 return out
180}
181
182/** `**` crosses folders, `*` and `?` stay inside one, `{a,b}` is either; a trailing `/**` also matches the folder itself. */
183export const globToRegExp = (glob: string): RegExp => new RegExp(`^${globBody(normalizePath(glob))}$`)
184
185export const matchersOf = (globs: readonly string[]): readonly RegExp[] => globs.map(globToRegExp)
186
187/** Whether a path relative to the project root is a test file (or a test folder) under the globs. */
188export const isTestPath = (relative: string, matchers: readonly RegExp[]): boolean => {
189 const path = normalizePath(relative)
190
191 return path !== '' && matchers.some(matcher => matcher.test(path))
192}
193
194/** A spelling the shell would expand into many files: it cannot be checked for existence, only matched. */
195export const hasWildcard = (path: string): boolean => /[*?[]/.test(path)
196
197// ---------------------------------------------------------------- shell
198
199type Token = { kind: 'word'; text: string } | { kind: 'op'; text: string }
200
201const SEPARATORS = new Set([';', '&&', '||', '|', '&', '\n'])
202const WORD_END = /[\s;&|<>()]/
203
204/** Skips the bodies of the here-documents opened on the line just ended; returns where the next line starts. */
205const skipHeredocs = (command: string, start: number, delimiters: readonly string[]): number => {
206 let index = start
207
208 for (const delimiter of delimiters) {
209 while (index < command.length) {
210 const end = command.indexOf('\n', index)
211 const line = command.slice(index, end < 0 ? command.length : end)
212 index = end < 0 ? command.length : end + 1
213
214 if (line.trim() === delimiter) {
215 break
216 }
217 }
218 }
219
220 return index
221}
222
223/** Reads a here-document's delimiter word after `<<` / `<<-`, quotes dropped. */
224const readDelimiter = (command: string, start: number): { delimiter: string; end: number } => {
225 let index = start
226
227 while (command.charAt(index) === ' ' || command.charAt(index) === '\t') {
228 index += 1
229 }
230
231 let delimiter = ''
232
233 while (index < command.length && !WORD_END.test(command.charAt(index))) {
234 const char = command.charAt(index)
235
236 if (char !== '"' && char !== "'" && char !== '\\') {
237 delimiter += char
238 }
239
240 index += 1
241 }
242
243 return { delimiter, end: index }
244}
245
246/**
247 * A shell command as words and operators: quotes and escapes resolved,
248 * comments and here-document bodies dropped, `>` and `>>` (with or without an
249 * fd) one `>` operator, an fd duplication (`2>&1`) nothing, and a subshell or
250 * command substitution read as a separator. Enough to see what a command
251 * writes, not a shell.
252 */
253export const lexShell = (command: string): Token[] => {
254 const tokens: Token[] = []
255 let word = ''
256 let hasWord = false
257 let heredocs: string[] = []
258 let index = 0
259
260 const flush = (): void => {
261 if (hasWord) {
262 tokens.push({ kind: 'word', text: word })
263 }
264
265 word = ''
266 hasWord = false
267 }
268
269 const op = (text: string): void => {
270 flush()
271 tokens.push({ kind: 'op', text })
272 }
273
274 while (index < command.length) {
275 const char = command.charAt(index)
276 const following = command.charAt(index + 1)
277
278 if (char === '\\') {
279 if (following !== '\n') {
280 word += following
281 hasWord = true
282 }
283
284 index += 2
285 continue
286 }
287
288 if (char === "'") {
289 const close = command.indexOf("'", index + 1)
290 const end = close < 0 ? command.length : close
291 word += command.slice(index + 1, end)
292 hasWord = true
293 index = end + 1
294 continue
295 }
296
297 if (char === '"') {
298 let cursor = index + 1
299
300 while (cursor < command.length && command.charAt(cursor) !== '"') {
301 if (command.charAt(cursor) === '\\' && cursor + 1 < command.length) {
302 cursor += 1
303 }
304
305 word += command.charAt(cursor)
306 cursor += 1
307 }
308
309 hasWord = true
310 index = cursor + 1
311 continue
312 }
313
314 if (char === '#' && !hasWord) {
315 const end = command.indexOf('\n', index)
316 index = end < 0 ? command.length : end
317 continue
318 }
319
320 if (char === ' ' || char === '\t') {
321 flush()
322 index += 1
323 continue
324 }
325
326 if (char === '\n') {
327 op('\n')
328 index = heredocs.length > 0 ? skipHeredocs(command, index + 1, heredocs) : index + 1
329 heredocs = []
330 continue
331 }
332
333 if (char === ';' || char === '(' || char === ')' || char === '`') {
334 op(';')
335 index += 1
336 continue
337 }
338
339 if (char === '$' && following === '(') {
340 op(';')
341 index += 2
342 continue
343 }
344
345 if (char === '|') {
346 op(following === '|' ? '||' : '|')
347 index += following === '|' || following === '&' ? 2 : 1
348 continue
349 }
350
351 if (char === '&' && following === '&') {
352 op('&&')
353 index += 2
354 continue
355 }
356
357 if (char === '&' && following === '>') {
358 op('>')
359 index += command.charAt(index + 2) === '>' ? 3 : 2
360 continue
361 }
362
363 if (char === '&') {
364 op('&')
365 index += 1
366 continue
367 }
368
369 if (char === '>') {
370 if (hasWord && /^\d+$/.test(word)) {
371 word = ''
372 hasWord = false
373 }
374
375 let cursor = index + 1
376
377 if (command.charAt(cursor) === '>' || command.charAt(cursor) === '|') {
378 cursor += 1
379 }
380
381 if (command.charAt(cursor) === '&') {
382 cursor += 1
383
384 while (cursor < command.length && /[0-9-]/.test(command.charAt(cursor))) {
385 cursor += 1
386 }
387
388 flush()
389 index = cursor
390 continue
391 }
392
393 op('>')
394 index = cursor
395 continue
396 }
397
398 if (char === '<') {
399 if (following === '<' && command.charAt(index + 2) !== '<') {
400 flush()
401 const start = command.charAt(index + 2) === '-' ? index + 3 : index + 2
402 const { delimiter, end } = readDelimiter(command, start)
403 heredocs.push(delimiter)
404 index = end
405 continue
406 }
407
408 op('<')
409 index += following === '<' ? 3 : 1
410 continue
411 }
412
413 word += char
414 hasWord = true
415 index += 1
416 }
417
418 flush()
419
420 return tokens
421}
422
423type Segment = { argv: string[]; writes: string[] }
424
425const segmentsOf = (tokens: readonly Token[]): Segment[] => {
426 const segments: Segment[] = []
427 let current: Segment = { argv: [], writes: [] }
428 let pending: '>' | '<' | null = null
429
430 for (const token of tokens) {
431 if (token.kind === 'op' && SEPARATORS.has(token.text)) {
432 segments.push(current)
433 current = { argv: [], writes: [] }
434 pending = null
435 continue
436 }
437
438 if (token.kind === 'op') {
439 pending = token.text === '>' ? '>' : '<'
440 continue
441 }
442
443 if (pending === '>') {
444 current.writes.push(token.text)
445 } else if (pending === null) {
446 current.argv.push(token.text)
447 }
448
449 pending = null
450 }
451
452 segments.push(current)
453
454 return segments.filter(segment => segment.argv.length > 0 || segment.writes.length > 0)
455}
456
457/** Words that run the next word as the command: `sudo rm`, `if rm`, `xargs rm`. */
458const WRAPPERS = new Set(['sudo', 'command', 'builtin', 'exec', 'nohup', 'time', 'env', 'xargs', 'nice', 'then', 'do', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}'])
459const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
460
461const commandOf = (argv: readonly string[]): string[] => {
462 let start = 0
463
464 while (start < argv.length) {
465 const word = argv[start] ?? ''
466
467 if (ASSIGNMENT.test(word)) {
468 start += 1
469 continue
470 }
471
472 if (!WRAPPERS.has(word)) {
473 break
474 }
475
476 start += 1
477
478 while (start < argv.length && (argv[start] ?? '').startsWith('-')) {
479 start += 1
480 }
481 }
482
483 return argv.slice(start)
484}
485
486/** The words that are not options, skipping the value after each flag in `valueFlags`; all after `--`. */
487const operandsOf = (args: readonly string[], valueFlags: ReadonlySet<string> = new Set()): string[] => {
488 const operands: string[] = []
489 let isRaw = false
490
491 for (let index = 0; index < args.length; index += 1) {
492 const arg = args[index] ?? ''
493
494 if (isRaw || !arg.startsWith('-') || arg === '-') {
495 operands.push(arg)
496 continue
497 }
498
499 if (arg === '--') {
500 isRaw = true
501 continue
502 }
503
504 if (valueFlags.has(arg)) {
505 index += 1
506 }
507 }
508
509 return operands
510}
511
512const baseName = (word: string): string => word.slice(word.lastIndexOf('/') + 1)
513
514const REMOVERS = new Set(['rm', 'unlink', 'rmdir', 'shred', 'trash'])
515const WRITERS_LAST = new Set(['cp', 'install', 'ln'])
516const WRITERS_ALL = new Set(['tee', 'truncate'])
517const OTHER_WRITERS = new Set(['touch', 'mkdir', 'patch', 'dd', 'black', 'isort', 'rustfmt', 'autopep8'])
518const GIT_WRITES = new Set(['apply', 'am', 'checkout', 'cherry-pick', 'clean', 'merge', 'mv', 'pull', 'rebase', 'reset', 'restore', 'revert', 'rm', 'stash', 'switch'])
519const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree'])
520const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'pip', 'pip3', 'uv', 'poetry', 'cargo', 'go'])
521const PACKAGE_WRITES = new Set([
522 'install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un', 'update', 'up', 'upgrade', 'ci', 'link', 'unlink',
523 'sync', 'lock', 'fmt', 'fix', 'get', 'mod', 'generate', 'init', 'new',
524])
525/** The flags that make each interpreter run code given inline, which may write anything. */
526const INLINE_CODE_FLAGS: Readonly<Record<string, readonly string[]>> = {
527 python: ['-c'],
528 python3: ['-c'],
529 node: ['-e', '--eval', '-p', '--print'],
530 bun: ['-e', '--eval'],
531 deno: ['eval'],
532 ruby: ['-e'],
533 perl: ['-e', '-E'],
534}
535const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash'])
536const FIX_FLAGS = new Set(['--write', '--fix', '--fix-only'])
537const SED_VALUE_FLAGS = new Set(['-e', '-f', '--expression', '--file', '-l'])
538const PERL_VALUE_FLAGS = new Set(['-e', '-E', '-M', '-m', '-I'])
539const DEVICE = /^\/dev\//
540
541export type ShellTarget = { kind: 'remove' | 'write'; path: string }
542export type ShellEffects = { targets: ShellTarget[]; isWrite: boolean }
543
544const isInPlace = (args: readonly string[]): boolean =>
545 args.some(arg => /^-[A-Za-z]*i/.test(arg) || arg.startsWith('--in-place'))
546
547/** What one simple command removes and writes, and whether it may change files at all. */
548const effectsOf = (argv: readonly string[]): ShellEffects => {
549 const [head = '', ...args] = commandOf(argv)
550 const name = baseName(head)
551 const operands = operandsOf(args)
552 const removes = (paths: readonly string[]): ShellTarget[] => paths.map(path => ({ kind: 'remove', path }))
553 const writes = (paths: readonly string[]): ShellTarget[] => paths.map(path => ({ kind: 'write', path }))
554 const last = operands.length >= 2 ? operands.slice(-1) : []
555
556 if (REMOVERS.has(name)) {
557 return { targets: removes(operands), isWrite: true }
558 }
559
560 if (name === 'mv') {
561 return { targets: [...removes(operands.length >= 2 ? operands.slice(0, -1) : operands), ...writes(last)], isWrite: true }
562 }
563
564 if (WRITERS_LAST.has(name)) {
565 return { targets: writes(last), isWrite: true }
566 }
567
568 if (WRITERS_ALL.has(name)) {
569 return { targets: writes(operands), isWrite: true }
570 }
571
572 if (name === 'dd') {
573 return { targets: writes(args.filter(arg => arg.startsWith('of=')).map(arg => arg.slice('of='.length))), isWrite: true }
574 }
575
576 if (OTHER_WRITERS.has(name)) {
577 return { targets: [], isWrite: true }
578 }
579
580 if (name === 'sed' && isInPlace(args)) {
581 const hasScriptFlag = args.some(arg => SED_VALUE_FLAGS.has(arg))
582 // BSD sed takes the backup suffix as its own word (`-i ''`); an empty word is never a file.
583 const files = operandsOf(args, SED_VALUE_FLAGS).filter(file => file !== '')
584
585 return { targets: writes(hasScriptFlag ? files : files.slice(1)), isWrite: true }
586 }
587
588 if (name === 'perl' && isInPlace(args)) {
589 return { targets: writes(operandsOf(args, PERL_VALUE_FLAGS)), isWrite: true }
590 }
591
592 if (name === 'git') {
593 const [sub = '', ...rest] = operandsOf(args, GIT_VALUE_FLAGS)
594 const subArgs = args.slice(args.indexOf(sub) + 1)
595 const subOperands = operandsOf(subArgs)
596
597 if (sub === 'rm') {
598 return { targets: removes(rest.length > 0 ? subOperands : []), isWrite: true }
599 }
600
601 if (sub === 'mv') {
602 return effectsOf(['mv', ...subArgs])
603 }
604
605 return { targets: [], isWrite: GIT_WRITES.has(sub) }
606 }
607
608 if (PACKAGE_MANAGERS.has(name)) {
609 const sub = operands[0]
610
611 return { targets: [], isWrite: (name === 'yarn' && sub === undefined) || (sub !== undefined && PACKAGE_WRITES.has(sub)) }
612 }
613
614 const script = SHELLS.has(name) ? args[args.indexOf('-c') + 1] : undefined
615
616 if (args.includes('-c') && script !== undefined) {
617 return shellEffects(script, '.')
618 }
619
620 if ((INLINE_CODE_FLAGS[name] ?? []).some(flag => args.includes(flag))) {
621 return { targets: [], isWrite: true }
622 }
623
624 return { targets: [], isWrite: (name === 'gofmt' && args.includes('-w')) || args.some(arg => FIX_FLAGS.has(arg)) }
625}
626
627/**
628 * What a Bash command may remove or write, paths resolved against the root
629 * (and against a `cd` earlier in the same command), and whether it looks like
630 * a write at all. Best effort: a script the command runs, `find -delete` or
631 * `xargs rm` hide their targets.
632 */
633export const shellEffects = (command: string, root: string): ShellEffects => {
634 const targets: ShellTarget[] = []
635 let isWrite = false
636 let base = root
637
638 for (const segment of segmentsOf(lexShell(command))) {
639 const [head = '', ...args] = commandOf(segment.argv)
640
641 if (head === 'cd' || head === 'pushd') {
642 const to = args.find(arg => !arg.startsWith('-'))
643 base = to === undefined || isUnplaceable(to) ? root : joinPath(base, to)
644 continue
645 }
646
647 for (const path of segment.writes) {
648 if (!DEVICE.test(path)) {
649 targets.push({ kind: 'write', path: joinPath(base, path) })
650 isWrite = true
651 }
652 }
653
654 const effects = effectsOf(segment.argv)
655 isWrite ||= effects.isWrite
656
657 for (const target of effects.targets) {
658 targets.push({ kind: target.kind, path: joinPath(base, target.path) })
659 }
660 }
661
662 return { targets, isWrite }
663}
664
665// ---------------------------------------------------------------- check command
666
667export const PROBE_FILES = [
668 'package.json',
669 'pnpm-lock.yaml',
670 'yarn.lock',
671 'bun.lockb',
672 'bun.lock',
673 'pyproject.toml',
674 'pytest.ini',
675 'setup.cfg',
676 'uv.lock',
677 'Cargo.toml',
678 'go.mod',
679] as const
680
681export type ProbeFile = (typeof PROBE_FILES)[number]
682
683export type ProjectProbe = { present: ReadonlySet<ProbeFile>; packageJson: string | null }
684
685export type CheckSource = 'setting' | 'package.json' | 'pytest' | 'cargo' | 'go'
686
687export type CheckCommand = { command: string; source: CheckSource }
688
689/** `npm init` writes this placeholder; it fails by design and tests nothing. */
690const NPM_PLACEHOLDER = 'no test specified'
691/** pytest's exit code when it collected no tests: nothing failed. */
692export const PYTEST_NO_TESTS = 5
693
694const testScriptOf = (packageJson: string | null): string | null => {
695 if (packageJson === null) {
696 return null
697 }
698
699 try {
700 const parsed: unknown = JSON.parse(packageJson)
701
702 if (typeof parsed !== 'object' || parsed === null || !('scripts' in parsed)) {
703 return null
704 }
705
706 const { scripts } = parsed
707
708 if (typeof scripts !== 'object' || scripts === null || !('test' in scripts)) {
709 return null
710 }
711
712 const { test } = scripts
713
714 return typeof test === 'string' && test.trim() !== '' && !test.includes(NPM_PLACEHOLDER) ? test : null
715 } catch {
716 return null
717 }
718}
719
720const nodeRunner = (present: ReadonlySet<ProbeFile>): string => {
721 if (present.has('pnpm-lock.yaml')) {
722 return 'pnpm test'
723 }
724
725 if (present.has('yarn.lock')) {
726 return 'yarn test'
727 }
728
729 // `bun test` is bun's own test runner; `bun run test` is the script.
730 if (present.has('bun.lockb') || present.has('bun.lock')) {
731 return 'bun run test'
732 }
733
734 return 'npm test'
735}
736
737/** The project's test command from the files at its root, or null when nothing says how to test it. */
738export const detectCommand = (probe: ProjectProbe): CheckCommand | null => {
739 const { present } = probe
740
741 if (present.has('package.json') && testScriptOf(probe.packageJson) !== null) {
742 return { command: nodeRunner(present), source: 'package.json' }
743 }
744
745 if (present.has('pyproject.toml') || present.has('pytest.ini') || present.has('setup.cfg')) {
746 return { command: present.has('uv.lock') ? 'uv run pytest -q' : 'pytest -q', source: 'pytest' }
747 }
748
749 if (present.has('Cargo.toml')) {
750 return { command: 'cargo test', source: 'cargo' }
751 }
752
753 if (present.has('go.mod')) {
754 return { command: 'go test ./...', source: 'go' }
755 }
756
757 return null
758}
759
760export const isPassing = (exitCode: number | null, check: CheckCommand): boolean =>
761 exitCode === 0 || (check.source === 'pytest' && exitCode === PYTEST_NO_TESTS)
762
763// ---------------------------------------------------------------- output
764
765export const TAIL_LINES = 40
766const MAX_LINE_CHARS = 400
767const SECONDS_PER_MINUTE = 60
768const TENTHS_UNDER_SECONDS = 10
769
770/** Color and cursor codes (CSI) and terminal titles and links (OSC), which a test runner prints to a TTY-less pipe anyway. */
771const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007/g
772
773const cleanStream = (text: string): string =>
774 text
775 .replace(ANSI, '')
776 .replace(/\r\n/g, '\n')
777 .split('\n')
778 .map(line => line.slice(line.lastIndexOf('\r') + 1))
779 .join('\n')
780 .replace(/^(?:[ \t]*\n)+/, '')
781 .trimEnd()
782
783/** The last lines of what the check printed, stdout then stderr, colors and progress redraws removed. */
784export const tailOf = (stdout: string, stderr: string, lines = TAIL_LINES): string =>
785 [cleanStream(stdout), cleanStream(stderr)]
786 .filter(text => text !== '')
787 .join('\n')
788 .split('\n')
789 .slice(-lines)
790 .map(line => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line))
791 .join('\n')
792
793export const formatDuration = (ms: number): string => {
794 const seconds = ms / MS_PER_SECOND
795
796 if (seconds < TENTHS_UNDER_SECONDS) {
797 return `${seconds.toFixed(1)}s`
798 }
799
800 if (seconds < SECONDS_PER_MINUTE) {
801 return `${Math.round(seconds)}s`
802 }
803
804 const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
805 const rest = Math.round(seconds % SECONDS_PER_MINUTE)
806
807 return `${minutes}m ${String(rest).padStart(2, '0')}s`
808}
809
810const exitText = (run: DoneGateRun): string => (run.exitCode === null ? 'did not finish' : `exit ${run.exitCode}`)
811
812export const LOCK_REASON = 'Tests are locked. If a test is wrong, say which one and why; the user can run /unlock-tests.'
813
814export const denyReason = (action: string, relative: string): string => `${LOCK_REASON} (Blocked: ${action} ${relative})`
815
816/** What the model reads when the gate sends it back. */
817export const blockReason = (run: DoneGateRun): string =>
818 [
819 `done-gate: the checks fail, so the work is not done. \`${run.command}\` ended with ${exitText(run)} after ${formatDuration(run.durationMs)}.`,
820 `Last ${TAIL_LINES} lines of output:`,
821 '```',
822 run.tail === '' ? '(no output)' : run.tail,
823 '```',
824 "Fix the code, not the tests, before saying it's done. If a test itself is wrong, stop and say which one and why.",
825 ].join('\n')
826
827export const passToast = (run: DoneGateRun): string => `done-gate: checks passed in ${formatDuration(run.durationMs)}`
828
829export const failToast = (run: DoneGateRun, roundsLeft: number): string =>
830 `done-gate: checks failed (${exitText(run)}): ${roundsLeft} ${roundsLeft === 1 ? 'round' : 'rounds'} left`
831
832export const giveUpToast = (rounds: number): string =>
833 `done-gate: checks still failing after ${rounds} ${rounds === 1 ? 'round' : 'rounds'}`
834
835export const NO_COMMAND_TOAST = 'done-gate: no test command found (package.json, pyproject.toml, Cargo.toml, go.mod); set checkCommand to turn the gate on'
836
837export type LockState = { isLocked: boolean; isOverridden: boolean }
838
839export type StatusInput = {
840 settings: Settings
841 lock: LockState
842 check: CheckCommand | null
843 root: string
844 hasChanges: boolean
845 lastRun: DoneGateRun | null
846 now: number
847}
848
849const lockLine = (lock: LockState): string => {
850 if (lock.isOverridden) {
851 return lock.isLocked ? 'locked for this session (/lock-tests)' : 'unlocked for this session (/unlock-tests)'
852 }
853
854 return lock.isLocked ? 'locked (lockTests setting)' : 'unlocked (lockTests setting)'
855}
856
857const agoText = (ms: number): string => (ms < MS_PER_SECOND ? 'just now' : `${formatDuration(ms)} ago`)
858
859/** What /done-gate prints. */
860export const statusReport = (input: StatusInput): string => {
861 const { settings, lock, check, lastRun } = input
862 const lines = [
863 `tests: ${lockLine(lock)}`,
864 `test globs: ${settings.globs.join(', ')}`,
865 `gate: ${settings.isGateOn ? `on, up to ${settings.maxRounds} rounds, timeout ${formatDuration(settings.timeoutMs)}` : 'off'}`,
866 check === null
867 ? `check command: none found in ${input.root}; set checkCommand`
868 : `check command: ${check.command} (${check.source === 'setting' ? 'checkCommand setting' : `detected: ${check.source}`})`,
869 `files changed since the last check: ${input.hasChanges ? 'yes' : 'no'}`,
870 ]
871
872 if (lastRun === null) {
873 lines.push('last run: none this session')
874 } else {
875 lines.push(
876 `last run: ${new Date(lastRun.at).toISOString()} (${agoText(input.now - lastRun.at)}), ${exitText(lastRun)}, ${formatDuration(lastRun.durationMs)}, ${lastRun.isPassed ? 'passed' : 'failed'}`,
877 lastRun.tail === '' ? '(no output)' : lastRun.tail,
878 )
879 }
880
881 return lines.join('\n')
882}
883types/index.d.ts 30 lines1export type DoneGateRun = {
2 /** When the check finished, milliseconds since the epoch. */
3 at: number
4 command: string
5 /** null when the check was killed by the timeout or could not start. */
6 exitCode: number | null
7 durationMs: number
8 isPassed: boolean
9 /** The last lines of stdout and stderr, ANSI codes stripped. */
10 tail: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'done-gate': {
16 /** null follows the `lockTests` setting; /lock-tests and /unlock-tests override it for the session. */
17 lockOverride: boolean | null
18 /** Test files created in this session: they stay editable, so TDD works under the lock. */
19 created: string[]
20 /** Whether a tool call changed files since the gate last let the model stop. */
21 hasChanges: boolean
22 /** How many times the gate sent the model back since the prompt `promptId` names. */
23 rounds: { promptId: string | null; count: number }
24 lastRun: DoneGateRun | null
25 /** Whether the "no check command found" note was shown this session. */
26 hasSaidNoCommand: boolean
27 }
28 }
29}
30