Runs the CI gate as a tool and a /gate command, its verdict read from the exit code and exit file; shows it in the status line; refuses piped gates, edits…

The CI gate, run by Claude Code itself instead of through a shell line the model writes. The model calls one tool with a checkout's path; the mod runs the gate there with no shell and no pipe, reads the verdict from the exit code held to the exit file, and answers in a few lines: the stages and their times, the test count, and for a red gate the failing stage's last 60 lines. The person runs the same with /gate and keeps the prompt.
exit=0 · 7 stages passed · 1620 tests (was 1631) · 1m21s · main @ 71ec66d, tree clean
typecheck 6s · lint 4s · check:reference 0s · check:determinism 0s · format:check 5s · test 1m04s · build 1s
log: <repo>/.git/gate.log
exit=1 · failed at lint (stage 2 of 7) · 12s · feat/b1 @ 55bd4e0, with uncommitted changes
typecheck 6s · lint 5s
log: <repo>/.git/worktrees/b1/gate.log
--- the last lines of lint ---
src/gate-probe.ts
2:3 error Unexpected 'debugger' statement no-debugger
The status line follows it: gate ⠹ test 6/7 · 1m48s · b1 · builder while it runs, then gate ✓ b1 @ bbb2222 · 1m16s, gate ✗ lint · b1 @ 55bd4e0, and · stale once HEAD moves.
Once per machine, after the playbook's marketplace is added (README → Mods):
claude plugin install gate@claude-playbook
Mods need Claude Code 2.1.287 or later. Turn it off in /plugin, Installed tab.
Two variables, read from the environment, so a project's .claude/settings.json env sets them:
| Variable | Default | What it is |
|---|---|---|
CLAUDE_GATE_COMMAND | pnpm gate | the gate, run by argv with no shell |
CLAUDE_GATE_EXIT_VAR | GATE_EXIT_FILE | the variable the gate writes exit=<code> to; empty when it writes none, and the exit code alone is the verdict |
A gate that prints == gate: <stage> lines (one per stage, then <stage> failed (exit <n>) or all stages passed) gets per-stage progress and times; any other gate is one stage.
exit=? with the reason, and never green. The exit file and the whole log go in the checkout's git folder (gate.exit, gate.log), never in the tree.Edit, Write, NotebookEdit) and tree moves (git switch, stash, rebase, reset, commit, …) in that checkout, and a second gate there;Tests 1631 passed (1631), jest's Tests: … total, pytest's == 5 passed in 0.12s ==; summed when a gate runs several): 1631 tests, , 6 skipped, 3 of 1631 tests failed, and (was 1640) when the count fell since the last green gate in the repo, so deleted or skipped tests show at once. It sits beside the verdict and is never it; a log with no summary this mod reads shows no count.cd /abs/checkout or git -C /abs/checkout. A shell variable (cd "$H") or a relative path needs the shell's own state, and a subagent's line with neither may run in a worktree no event reports: such a push goes through with a note that it was not checked and why, and such a gate is not recorded. The note names the literal forms, so the next line gets it right.~/.claude/gate-runs.jsonl (the repo, the checkout, the branch and commit, the agent, how it ran, the verdict, each stage's time), beside the call cap's builder-calls.jsonl. Which stage is slow, which fails most, is read there.claude --plugin-dir mods/gate # loads it for one session, reloads on save claude plugin validate mods/gate claude plugin test mods/gate # 17 tests tsc -p mods/gate # once Claude Code has loaded it: it writes the tsconfig
The tests were mutation-checked on 2026-10-05: thirty-three deliberate breaks (the exit file's say, each guard, the push guard's arming, record, red, unread and moved-commit cases, the subagent note, the stage tail, the stage count, staleness, the live stage, the environment, the Bash record, the ledger, each of the three rules that tell a command from a mention, each test runner's summary, skips, failures and the fall since the last green, the shell-variable note and its fallback, and the command's own prefix) each turned a test red. /gate's note to the model has no answer in the test kit; it was seen live the same day.
hooks/register.ts 534 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3// The gate's verdict is its exit code, held to the exit file it writes, never the prose it prints
4// (the playbook's templates/CLAUDE.md, "Capture exit codes directly"). Configured per repo through
5// the environment, as the call cap is, so a project's settings `env` can set it:
6// CLAUDE_GATE_COMMAND the gate, run by argv with no shell (default `pnpm gate`)
7// CLAUDE_GATE_EXIT_VAR the variable the gate writes `exit=<code>` to (default `GATE_EXIT_FILE`;
8// empty: the gate writes none, and its exit code alone is the verdict)
9// A gate that prints `== gate: <stage>` lines (still-water's scripts/gate.sh) gets per-stage
10// progress and times; any other gate is one stage.
11
12const TOOL = 'mcp__gate__run'
13const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
14const TICK_MS = 250
15const STALE_EVERY = 40 // ticks: the shown checkout's HEAD and tree are read every 10 s
16const TAIL_LINES = 60
17const MARK = /^== gate: (.+)$/
18const FAILED = /^(\S+) failed \(exit (\d+)\)/
19// Where a shell line starts a command: its start, after `;`, `&`, `|`, `(`, `$(` or a newline,
20// past any `VAR=value` assignments. The guards match there alone, on the line `commandsOf` leaves.
21const AT_COMMAND = String.raw`(?:^|[;&|(\n]|\$\()[ \t]*(?:[A-Za-z_]\w*=\S*[ \t]+)*`
22const PUSH = new RegExp(String.raw`${AT_COMMAND}git\s+(?:-C\s+\S+\s+)?push\b`)
23const PUSH_EXEMPT = /\s(?:--dry-run|-n|--delete|-d)(?=\s|$)/
24const MUTATES = new RegExp(String.raw`${AT_COMMAND}git\s+(?:-C\s+\S+\s+)?(?:switch|checkout|stash|rebase|reset|merge|pull|commit|cherry-pick|am|restore|clean)\b`)
25
26type Checkout = { top: string; repo: string; gitDir: string; sha: string; branch: string; isClean: boolean }
27
28// One gate run. `exit` stays null while it runs, and when the verdict could not be read honestly
29// (`reason` says why): a null exit is never green.
30type Run = Checkout & {
31 via: 'tool' | 'command' | 'bash'
32 agent: string | null
33 startedAt: number
34 endedAt: number | null
35 stage: string | null
36 stages: { name: string; ms: number }[]
37 total: number | null
38 exit: number | null
39 failedStage: string | null
40 reason: string | null
41 tail: string
42 logFile: string | null
43 tests: Tests | null
44 testsBefore: number | null
45}
46
47// The test runner's own summary line, where the log has one this mod reads (vitest, jest,
48// pytest): information beside the verdict, never the verdict, and left out rather than guessed.
49type Tests = { passed: number; failed: number; skipped: number; total: number }
50
51// Session-local, never persisted: the gates running now (a reload kills their children with the
52// module) and the run the status line shows. The durable record is $.store (`last:<checkout>`,
53// `armed:<repo>`, `stages:<repo>`) and the ledger, ~/.claude/gate-runs.jsonl.
54const running = new Map<string, Run>()
55let shown: Run | null = null
56let isShownStale = false
57let tick = 0
58
59async function configOf($: EngineInterface) {
60 const command = ((await $.env.get('CLAUDE_GATE_COMMAND')) ?? 'pnpm gate').trim()
61 const exitVar = ((await $.env.get('CLAUDE_GATE_EXIT_VAR')) ?? 'GATE_EXIT_FILE').trim()
62 return { command, argv: command.split(/\s+/), exitVar }
63}
64
65const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
66
67// `pnpm gate` matches `pnpm run gate` too.
68function gatePattern(command: string) {
69 const [head = '', ...rest] = command.split(/\s+/)
70 const tail = rest.length === 0 ? '' : `\\s+(?:run\\s+)?${rest.map(esc).join('\\s+')}`
71 return new RegExp(`${AT_COMMAND}${esc(head)}${tail}(?=$|[\\s;&|)])`)
72}
73
74// What the shell runs, not what it mentions: a heredoc's body goes and each quoted string becomes
75// one word, so a commit message, an echo, a grep or a file written that names the gate is no gate.
76function commandsOf(command: string) {
77 return command
78 .replace(/<<-?[ \t]*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2(?=[ \t]*(?:\n|$))/g, '<<H$3')
79 .replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, 'Q')
80}
81
82// Piped: a `|` (not `||`) after the gate, before the next `;`, `&` or newline. `2>&1` is a
83// redirection, not a list, so it is taken out first.
84function isPiped(command: string, gate: RegExp) {
85 const plain = command.replace(/\d*>&\d+/g, '')
86 const m = gate.exec(plain)
87 if (m === null) return false
88 const segment = /^[^;&\n]*/.exec(plain.slice(m.index + m[0].length))?.[0] ?? ''
89 return segment.replace(/\|\|/g, '').includes('|')
90}
91
92// The directory a shell line names for itself, as written: a `cd <dir>` or `git -C <dir>`.
93function namedDir(command: string) {
94 const m = /(?:^\s*|[;&|(]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/.exec(command) ?? /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/.exec(command)
95 return m?.[1]?.replace(/^["']|["']$/g, '') ?? null
96}
97
98// Where a Bash call runs: the absolute path it names; with none named, the session's directory
99// for the main loop. A relative path or a shell variable needs the shell's own state, and a
100// subagent's shell may sit in a worktree no event reports: those are unknown, never the session's.
101async function dirFor($: EngineInterface, command: string, agentId: string | undefined) {
102 const named = namedDir(command)
103 if (named !== null) return named.startsWith('/') ? named : null
104 return agentId === undefined ? await $.session.cwd() : null
105}
106
107// Why dirFor knew no checkout, and the fix, for the note that says so.
108function unknownWhy(command: string) {
109 const named = namedDir(command)
110 const why = named === null
111 ? "the line names no checkout, and a subagent's shell may sit in a worktree no event reports"
112 : named.includes('$')
113 ? `it names the checkout through a shell variable (${named}), which the mod cannot read`
114 : `it names the checkout by a relative path (${named}), which needs the shell's own directory`
115 return `${why}. Name it with a literal absolute path: \`cd /abs/checkout && …\` or \`git -C /abs/checkout …\``
116}
117
118async function git($: EngineInterface, dir: string, ...args: string[]) {
119 try {
120 const r = await $.process.run(['git', '-C', dir, ...args])
121 return r.exitCode === 0 ? r.stdout.trim() : null
122 } catch {
123 return null
124 }
125}
126
127async function checkoutAt($: EngineInterface, dir: string): Promise<Checkout | null> {
128 const top = await git($, dir, 'rev-parse', '--show-toplevel')
129 if (top === null || top === '') return null
130 const [repo, gitDir, sha, branch, status] = await Promise.all([
131 git($, top, 'rev-parse', '--path-format=absolute', '--git-common-dir'),
132 git($, top, 'rev-parse', '--absolute-git-dir'),
133 git($, top, 'rev-parse', '--short', 'HEAD'),
134 git($, top, 'branch', '--show-current'),
135 git($, top, 'status', '--porcelain'),
136 ])
137 if (repo === null || gitDir === null || sha === null || status === null) return null
138 return { top, repo, gitDir, sha, branch: branch === null || branch === '' ? 'detached' : branch, isClean: status === '' }
139}
140
141// The checkout a file lies in, from its nearest existing folder (a Write may create the rest).
142async function topOf($: EngineInterface, path: string) {
143 let dir = path
144 while (dir.includes('/') && dir !== '/') {
145 dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
146 const top = await git($, dir, 'rev-parse', '--show-toplevel')
147 if (top !== null && top !== '') return top
148 }
149 return null
150}
151
152async function agentLabel($: EngineInterface, agentId: string | undefined) {
153 if (agentId === undefined) return null
154 const found = (await $.agent.list()).find(a => a.id === agentId)
155 return found?.type ?? 'agent'
156}
157
158function span(ms: number) {
159 const s = Math.round(ms / 1000)
160 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
161}
162
163function basename(path: string) {
164 return path.slice(path.lastIndexOf('/') + 1)
165}
166
167function begin(co: Checkout, via: Run['via'], agent: string | null, startedAt: number, total: number | null): Run {
168 const run: Run = {
169 ...co, via, agent, startedAt, total,
170 endedAt: null, stage: null, stages: [], exit: null, failedStage: null, reason: null, tail: '', logFile: null,
171 tests: null, testsBefore: null,
172 }
173 running.set(co.top, run)
174 shown = run
175 isShownStale = false
176 return run
177}
178
179async function finish($: EngineInterface, run: Run) {
180 run.endedAt = await $.clock.now()
181 run.stage = null
182 running.delete(run.top)
183 const { tail: _tail, ...kept } = run
184 await $.store.set(`last:${run.top}`, kept)
185 if (run.exit === 0) {
186 await $.store.set(`armed:${run.repo}`, true)
187 if (run.stages.length > 0) await $.store.set(`stages:${run.repo}`, run.stages.length)
188 if (run.tests !== null) await $.store.set(`tests:${run.repo}`, run.tests.total)
189 }
190 await ledger($, run)
191 await showStatus($)
192 $.ui.toast(headline(run))
193}
194
195async function ledger($: EngineInterface, run: Run) {
196 const home = await $.env.get('HOME')
197 if (home === undefined) {
198 $.ui.log('gate: HOME is unset, so this run was not written to ~/.claude/gate-runs.jsonl', { to: 'debug' })
199 return
200 }
201 const line = JSON.stringify({
202 at: new Date(run.startedAt).toISOString(),
203 repo: run.repo, checkout: run.top, branch: run.branch, sha: run.sha, isClean: run.isClean,
204 via: run.via, agent: run.agent, exit: run.exit, failedStage: run.failedStage, reason: run.reason,
205 ms: (run.endedAt ?? run.startedAt) - run.startedAt, stages: run.stages, tests: run.tests,
206 })
207 const path = `${home}/.claude/gate-runs.jsonl`
208 await $.process.run(['sh', '-c', 'cat >> "$0"', path], { stdin: `${line}\n` }).catch(() => {
209 $.ui.log(`gate: could not append to ${path}`, { to: 'debug' })
210 })
211}
212
213// Runs the gate in a checkout the mod owns end to end: no shell, no pipe, the verdict from the exit
214// code and the exit file.
215async function runGate($: EngineInterface, co: Checkout, via: Run['via'], agent: string | null) {
216 const cfg = await configOf($)
217 const total = await $.store.get(`stages:${co.repo}`)
218 const run = begin(co, via, agent, await $.clock.now(), typeof total === 'number' ? total : null)
219 const testsBefore = await $.store.get(`tests:${co.repo}`)
220 run.testsBefore = typeof testsBefore === 'number' ? testsBefore : null
221 run.logFile = `${co.gitDir}/gate.log`
222 const exitFile = `${co.gitDir}/gate.exit`
223 await showStatus($)
224 let log = ''
225 let partial = ''
226 let stageAt = run.startedAt
227 let code: number | null = null
228 const close = (now: number) => {
229 if (run.stage !== null) run.stages.push({ name: run.stage, ms: now - stageAt })
230 run.stage = null
231 stageAt = now
232 }
233 try {
234 if (cfg.exitVar !== '') await $.fs.write(exitFile, '') // a previous run's verdict is never read
235 const child = $.process.spawn({ argv: cfg.argv, cwd: co.top, env: cfg.exitVar === '' ? {} : { [cfg.exitVar]: exitFile } })
236 for await (const chunk of child) {
237 log += chunk.text
238 if (chunk.stream !== 'stdout') continue
239 const lines = (partial + chunk.text).split('\n')
240 partial = lines.pop() ?? ''
241 for (const line of lines) {
242 const said = MARK.exec(line)?.[1]
243 if (said === undefined) continue
244 const now = await $.clock.now()
245 const failed = FAILED.exec(said)
246 if (failed !== null) {
247 run.failedStage = failed[1] ?? run.stage
248 close(now)
249 } else if (said === 'all stages passed') {
250 close(now)
251 } else {
252 close(now)
253 run.stage = said
254 await showStatus($)
255 }
256 }
257 }
258 const ended = await child.result
259 code = ended.code
260 if (code === null) run.reason = `the gate was ended by ${ended.signal ?? 'a signal'}`
261 } catch (err) {
262 run.reason = `\`${cfg.command}\` could not run in ${co.top}: ${err instanceof Error ? err.message : String(err)}`
263 }
264 if (run.reason === null && code !== null) {
265 if (cfg.exitVar === '') {
266 run.exit = code
267 } else {
268 const said = /^exit=(\d+)/m.exec(await $.fs.read(exitFile).catch(() => ''))?.[1]
269 if (said === undefined) run.reason = `the gate exited ${code} but wrote nothing to ${cfg.exitVar}`
270 else if ((said === '0') !== (code === 0)) run.reason = `the gate exited ${code} but its exit file says exit=${said}`
271 else run.exit = Number(said)
272 }
273 }
274 if (run.exit !== 0 && run.failedStage === null) run.failedStage = run.stage // ended mid-stage
275 close(await $.clock.now())
276 run.tests = testsOf(log)
277 if (run.exit !== 0) run.tail = tailOf(log, run.failedStage)
278 await $.fs.write(run.logFile, log).catch(() => {
279 run.logFile = null
280 })
281 await finish($, run)
282 return run
283}
284
285function tailOf(log: string, stage: string | null) {
286 const at = stage === null ? -1 : log.lastIndexOf(`== gate: ${stage}\n`)
287 return (at < 0 ? log : log.slice(at)).trimEnd().split('\n').slice(-TAIL_LINES).join('\n')
288}
289
290// The summary lines, summed when a gate runs more than one suite:
291// vitest " Tests 3 failed | 1625 passed | 6 skipped (1634)"
292// jest "Tests: 1 failed, 5 passed, 6 total"
293// pytest "===== 5 passed, 1 skipped in 0.12s ====="
294function testsOf(log: string): Tests | null {
295 const text = log.replace(/\x1b\[[0-9;]*m/g, '')
296 const found: Tests[] = []
297 for (const m of text.matchAll(/^\s*Tests\s+((?:\d+ [a-z]+(?: \| )?)+)\s*\((\d+)\)\s*$/gm)) found.push(countsOf(m[1] ?? '', Number(m[2])))
298 for (const m of text.matchAll(/^Tests:\s+(.*?)(\d+) total\s*$/gm)) found.push(countsOf(m[1] ?? '', Number(m[2])))
299 for (const m of text.matchAll(/^=+ ((?:\d+ [a-z]+,? ?)+) in [\d.]+s(?: \([^)]*\))? =+$/gm)) found.push(countsOf(m[1] ?? '', null))
300 if (found.length === 0) return null
301 return found.reduce((a, b) => ({
302 passed: a.passed + b.passed, failed: a.failed + b.failed, skipped: a.skipped + b.skipped, total: a.total + b.total,
303 }))
304}
305
306function countsOf(parts: string, total: number | null): Tests {
307 const sum = (words: string) => [...parts.matchAll(new RegExp(`(\\d+) (?:${words})\\b`, 'g'))].reduce((s, m) => s + Number(m[1]), 0)
308 const passed = sum('passed')
309 const failed = sum('failed|errors?')
310 const skipped = sum('skipped|todo|xfailed')
311 return { passed, failed, skipped, total: total ?? passed + failed + skipped }
312}
313
314// "1631 tests", "3 of 1631 tests failed", ", 6 skipped", and "(was 1640)" when the count fell
315// since the last green gate in this repo: tests deleted or skipped show here first.
316function testsLine(run: Run) {
317 const t = run.tests
318 if (t === null) return null
319 const count = t.failed > 0 ? `${t.failed} of ${t.total} tests failed` : `${t.total} tests`
320 const skipped = t.skipped > 0 ? `, ${t.skipped} skipped` : ''
321 const fell = run.testsBefore !== null && t.total < run.testsBefore ? ` (was ${run.testsBefore})` : ''
322 return `${count}${skipped}${fell}`
323}
324
325function headline(run: Run) {
326 const where = `${basename(run.top)} @ ${run.sha}`
327 const took = span((run.endedAt ?? run.startedAt) - run.startedAt)
328 if (run.exit === 0) return `gate ✓ ${where} in ${took}`
329 if (run.exit === null) return `gate ? ${where}: ${run.reason ?? 'no verdict'}`
330 return `gate ✗ ${run.failedStage ?? `exit=${run.exit}`} · ${where} in ${took}`
331}
332
333// What the model reads: the verdict first, then the times, the log's path, and for a red gate the
334// failing stage's last lines.
335function verdict(run: Run) {
336 const took = span((run.endedAt ?? run.startedAt) - run.startedAt)
337 const tree = run.isClean ? 'tree clean' : 'with uncommitted changes'
338 const at = `${run.branch} @ ${run.sha}, ${tree}`
339 const n = run.stages.length
340 const what = run.exit === null
341 ? `exit=? · ${run.reason ?? 'no verdict'}`
342 : run.exit === 0
343 ? `exit=0 · ${n === 0 ? 'passed' : `${n} stages passed`}`
344 : `exit=${run.exit} · ${run.failedStage === null ? 'failed' : `failed at ${run.failedStage} (stage ${n} of ${run.total ?? '?'})`}`
345 const head = [what, testsLine(run), took, at].filter(part => part !== null).join(' · ')
346 const lines = [head]
347 if (n > 0) lines.push(run.stages.map(s => `${s.name} ${span(s.ms)}`).join(' · '))
348 if (run.logFile !== null) lines.push(`log: ${run.logFile}`)
349 if (run.tail !== '') lines.push('', `--- the last lines of ${run.failedStage ?? 'the log'} ---`, run.tail)
350 return lines.join('\n')
351}
352
353function statusOf(run: Run, now: number) {
354 const name = basename(run.top)
355 const who = run.agent === null ? '' : ` · ${run.agent}`
356 if (run.endedAt === null) {
357 const spin = SPINNER[tick % SPINNER.length]
358 const stage = run.via === 'bash' ? 'running (bash)' : run.stage ?? 'starting'
359 const of = run.stage === null || run.via === 'bash' ? '' : ` ${run.stages.length + 1}${run.total === null ? '' : `/${run.total}`}`
360 return `gate ${spin} ${stage}${of} · ${span(now - run.startedAt)} · ${name}${who}`
361 }
362 const sha = `${run.sha}${run.isClean ? '' : '+'}`
363 const stale = isShownStale ? ' · stale' : ''
364 if (run.exit === 0) return `gate ✓ ${name} @ ${sha} · ${span(run.endedAt - run.startedAt)}${stale}`
365 if (run.exit === null) return `gate ? ${name} @ ${sha} · no verdict${stale}`
366 return `gate ✗ ${run.failedStage ?? `exit=${run.exit}`} · ${name} @ ${sha}${stale}`
367}
368
369async function showStatus($: EngineInterface) {
370 const latest = [...running.values()].at(-1) ?? shown
371 if (latest === null) return
372 $.ui.status(statusOf(latest, await $.clock.now()))
373}
374
375// HEAD moved, or a tree gated clean is dirty now: the verdict no longer speaks for the checkout.
376async function checkStale($: EngineInterface) {
377 if (shown === null || running.size > 0) return
378 const co = await checkoutAt($, shown.top)
379 const isStale = co === null || co.sha !== shown.sha || (shown.isClean && !co.isClean)
380 if (isStale !== isShownStale) {
381 isShownStale = isStale
382 await showStatus($)
383 }
384}
385
386function withNote(res: ToolCallResult, note: string): ToolCallResult {
387 return res.deny !== undefined || res.isError === true ? res : { ...res, context: [...(res.context ?? []), note] }
388}
389
390export const register: Register = on => {
391 on('session.start', async ($, e, next) => {
392 const started = await next(e)
393 const cfg = await configOf($)
394 await $.tool.register({
395 name: 'run',
396 description: `Runs the CI gate (\`${cfg.command}\`) in one checkout and returns its verdict, read from the gate's exit code${cfg.exitVar === '' ? '' : ` and the exit file it writes (${cfg.exitVar})`}, never from the prose it prints. Use it instead of running the gate through Bash. It blocks until the gate ends, which takes minutes. It returns exit=<code>, the stages and their times, and for a red gate the failing stage's last ${TAIL_LINES} lines; the whole log stays on disk at the path it names. Edits to the checkout are refused while it runs.`,
397 inputSchema: {
398 type: 'object',
399 properties: { checkout: { type: 'string', description: "Absolute path of the checkout to gate: your worktree's root, or the main checkout." } },
400 required: ['checkout'],
401 },
402 })
403 await $.command.register({
404 name: 'gate',
405 description: 'Run the CI gate in this checkout (or the path given) with no Claude turn; the verdict lands in the status line and the conversation.',
406 })
407 // The session's own checkout shows its last verdict from the start, stale or not.
408 const home = await checkoutAt($, e.cwd)
409 const last = home === null ? undefined : await $.store.get(`last:${home.top}`)
410 if (last !== undefined && last !== null && typeof last === 'object') {
411 shown = { ...(last as Run), tail: '' }
412 await checkStale($)
413 await showStatus($)
414 }
415 $.clock.every(TICK_MS, () => {
416 tick += 1
417 if (running.size > 0) void showStatus($)
418 else if (tick % STALE_EVERY === 0) void checkStale($)
419 })
420 return started
421 })
422
423 // A pattern, not the name: the tool names a machine declares are its own connected MCP tools.
424 on('tool.call', { tool: /^mcp__gate__run$/ }, async ($, e) => {
425 const checkout = (e as unknown as { checkout?: unknown }).checkout
426 if (typeof checkout !== 'string' || !checkout.startsWith('/')) {
427 return { deny: "gate: name the checkout by its absolute path (your worktree's root, or the main checkout)." }
428 }
429 const co = await checkoutAt($, checkout)
430 if (co === null) return { deny: `gate: ${checkout} is not a git checkout.` }
431 const busy = running.get(co.top)
432 if (busy !== undefined) return { deny: `gate: a gate already runs in ${co.top} (${busy.stage ?? 'starting'}); wait for its verdict.` }
433 const run = await runGate($, co, 'tool', await agentLabel($, e.agentId))
434 return { result: verdict(run) }
435 })
436
437 on('command.run', { command: 'gate' }, async ($, e) => {
438 const dir = e.args.trim() === '' ? await $.session.cwd() : e.args.trim()
439 const co = await checkoutAt($, dir)
440 if (co === null) return { text: `${dir} is not a git checkout.` }
441 const busy = running.get(co.top)
442 if (busy !== undefined) return { text: `a gate already runs in ${co.top} (${busy.stage ?? 'starting'}).` }
443 // The run outlives this answer (the API's own pattern for a child that runs on after its
444 // hook): the person keeps the prompt, and the verdict reaches the model as a note.
445 void (async () => {
446 const run = await runGate($, co, 'command', null)
447 await $.session
448 .append({ message: { type: 'user', content: [{ type: 'text', text: `The gate the person ran with /gate has ended.\n${verdict(run)}` }] } })
449 .catch(() => $.ui.log('gate: the verdict could not be added to the conversation', { to: 'debug' }))
450 })()
451 return { text: `started in ${co.top} @ ${co.sha}. The status line follows it; the verdict lands in the conversation when it ends.` }
452 })
453
454 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
455 const command = e.command
456 const runs = commandsOf(command)
457 const cfg = await configOf($)
458 const gate = gatePattern(cfg.command)
459 if (gate.test(runs)) {
460 const how = `Call ${TOOL} with the checkout's absolute path, or run \`${cfg.exitVar === '' ? '' : `${cfg.exitVar}=<scratch>/gate.exit `}${cfg.command} > <scratch>/gate.log 2>&1\` and read the ${cfg.exitVar === '' ? 'exit code' : 'exit file'}.`
461 if (isPiped(runs, gate)) return { deny: `gate: \`${cfg.command}\` piped into another command reports that command's exit status, not the gate's. ${how}` }
462 if (cfg.exitVar !== '' && !new RegExp(`(?:^|[\\s;&(])${esc(cfg.exitVar)}=\\S`).test(runs)) {
463 return { deny: `gate: \`${cfg.command}\` without ${cfg.exitVar} leaves no verdict but its prose. ${how}` }
464 }
465 if (PUSH.test(runs)) return { deny: 'gate: a gate and a push in one command push whatever the gate says. Gate, read the verdict, then push.' }
466 if (e.run_in_background === true) {
467 return withNote(await next(e), `gate: a gate run in the background is not recorded, so the push guard will not count it. Call ${TOOL} instead.`)
468 }
469 const dir = await dirFor($, command, e.agentId)
470 const co = dir === null ? null : await checkoutAt($, dir)
471 if (co === null || dir === null) {
472 return withNote(await next(e), `gate: this gate is not recorded, so the push guard will not count it: ${unknownWhy(command)}; or call ${TOOL}.`)
473 }
474 if (running.has(co.top)) return { deny: `gate: a gate already runs in ${co.top}; wait for its verdict.` }
475 const run = begin(co, 'bash', await agentLabel($, e.agentId), await $.clock.now(), null)
476 await showStatus($)
477 const res = await next(e)
478 if (cfg.exitVar === '') {
479 run.reason = 'a gate run through Bash with no exit file leaves no verdict the mod can read'
480 } else {
481 const named = new RegExp(`${esc(cfg.exitVar)}=("[^"]+"|'[^']+'|\\S+)`).exec(command)?.[1]?.replace(/^["']|["']$/g, '') ?? ''
482 const file = named.startsWith('/') ? named : `${dir}/${named}`
483 const said = /^exit=(\d+)/m.exec(await $.fs.read(file).catch(() => ''))?.[1]
484 if (said === undefined) run.reason = `no exit=<code> in ${file}`
485 else run.exit = Number(said)
486 }
487 await finish($, run)
488 return res
489 }
490
491 if (PUSH.test(runs) && !PUSH_EXEMPT.test(runs)) {
492 const dir = await dirFor($, command, e.agentId)
493 if (dir === null) {
494 return withNote(await next(e), `gate: this push's gate was not checked: ${unknownWhy(command)}.`)
495 }
496 const co = await checkoutAt($, dir)
497 // Only a repo that has passed a gate under this mod is guarded: one with no gate never is.
498 if (co === null || (await $.store.get(`armed:${co.repo}`)) !== true) return next(e)
499 if (running.has(co.top)) return { deny: `gate: a gate is running in ${co.top}; push once its verdict is green.` }
500 const last = (await $.store.get(`last:${co.top}`)) as Run | undefined
501 if (last === undefined || last === null) {
502 return { deny: `gate: no gate has run in ${co.top}. Call ${TOOL} with that path (or the person runs /gate), then push.` }
503 }
504 if (last.exit !== 0) {
505 const why = last.exit === null ? `could not be read (${last.reason ?? 'no verdict'})` : `failed at ${last.failedStage ?? `exit=${last.exit}`}`
506 return { deny: `gate: the last gate in ${co.top} ${why} at ${last.sha}. Fix it, gate again, then push.` }
507 }
508 const res = await next(e)
509 if (last.sha !== co.sha || !last.isClean) {
510 return withNote(res, `gate: the last green gate in ${co.top} was at ${last.sha}${last.isClean ? '' : ' with uncommitted changes'}; this push is ${co.sha}. CI checks the difference.`)
511 }
512 return res
513 }
514
515 if (running.size > 0 && MUTATES.test(runs)) {
516 const dir = await dirFor($, command, e.agentId)
517 const top = dir === null ? null : await git($, dir, 'rev-parse', '--show-toplevel')
518 const busy = top === null ? undefined : running.get(top)
519 if (busy !== undefined) return { deny: `gate: a gate is running in ${busy.top}; changing its tree now changes what it checks. Wait for its verdict.` }
520 }
521 return next(e)
522 })
523
524 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
525 if (running.size === 0) return next(e)
526 const path = 'notebook_path' in e ? e.notebook_path : e.file_path
527 const top = await topOf($, path)
528 const busy = top === null ? undefined : running.get(top)
529 if (busy === undefined) return next(e)
530 const now = await $.clock.now()
531 return { deny: `gate: a gate is running in ${busy.top} (${busy.stage ?? 'running'}, ${span(now - busy.startedAt)}); a change now alters what it checks. Wait for its verdict, then edit.` }
532 })
533}
534