Enforces the forge phase gate: shows whether the current phase's Verifiable gate is green on exactly the files you have, and sends back a forge stage whose…

The forge phase gate, enforced inside Claude Code.
Phase 3 · Billing API ✓ gate green 4m ago + written checks [ Run gate ]
Phase 3 · Billing API ◐ changed since the last run [ Run gate ]
Phase 3 · Billing API ✗ gate red: bun run gate:phase-03 [ Run gate ]
Phase 5 · App icon ◐ written checks, not checked [ Checked ]
Branch: lines in wiki/plan.md. Off a phase branch the mod does nothing. /gate 3 pins a phase and /gate auto unpins it.<placeholders>.wiki/ is left out, because forge writes its build log and learnings after the gate runs. A pass is remembered across sessions.&& or ; may come before it, and only && after it. So true || bun test, echo bun test, cd sub && bun test, bun test | tail and a run moved to the background don't count.bun run gate && tsc --noEmit counts every gate command in it. If the chain fails, only its last command is marked failed.cd <repo root> && in front, since its shell directory isn't known.git switch -c phase/3-… counts./gate (or Run gate) also counts. It lists the commands and asks before it runs anything.FORGE_RESULT {"skill":…,"phase":3,"gate":"green",…}. When that line says "gate":"green", it's checked against what actually ran on the current files. If it isn't true, the stage is sent back once with what's missing, told to run the gate or to change "gate" to "red" or "deferred". The phase is the one the line names, so forge-ship's line is still checked after it lands on main. It works in the main session and in subagents. An honest red, deferred or blocked line always passes./gate) runs the phase's gate commands now, on the files as they are, and shows the result in the band. It's your own way to check without asking Claude, e.g. before trusting a "done". Most of the time you never press it: Claude's runs update the band by themselves.Commands: /gate runs the gate (it asks first), /gate status reports without running, /gate <n> pins phase n when you're off its branch, /gate auto unpins.
Verifiable gate:. The mod only reads it.Inside the normal loop the mod mostly confirms what the skills already do. Its value is the visible state, catching a skipped or stale run, and remembering a pass across sessions.
Limits:
.gitignore changes the tree it ran on, so it never reads green. Ignore its outputs.cds to this repo's root./gate <n> pins a phase.Turn the guardDoneClaims setting off to disable both guards (result lines and phrases). The band and /gate keep working.
Install: see ../README.md.
hooks/register.tsx 646 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GateCheck, GatePhase, GateRun } from '../types'
5
6const phaseAtom = atom({ plugin: 'forge-gate', key: 'phase' } as const, null)
7const runsAtom = atom({ plugin: 'forge-gate', key: 'runs' } as const, {})
8const checkedAtom = atom({ plugin: 'forge-gate', key: 'checked' } as const, null)
9const treeAtom = atom({ plugin: 'forge-gate', key: 'tree' } as const, '')
10const runningAtom = atom({ plugin: 'forge-gate', key: 'isRunning' } as const, false)
11
12type Runs = Record<string, GateRun>
13type ParsedPhase = { n: number; title: string; branch: string; gate: string; candidates: string[]; hasProse: boolean }
14type Verdict =
15 | { kind: 'green' }
16 | { kind: 'red'; failing: GateRun[] }
17 | { kind: 'unrun'; missing: string[] }
18 | { kind: 'manual' }
19 | { kind: 'checked' }
20
21const RUN_TIMEOUT_MS = 600_000
22const TAIL_CHARS = 600
23
24// ---------- Reading the plan ----------
25
26const PHASE_HEAD = /^## Phase (\d+)\s*[—–-]\s*(.+)$/gm
27
28// Tools that run checks; anything else must be found on PATH before it counts.
29const RUNNERS = new Set([
30 'bun', 'bunx', 'npm', 'npx', 'pnpm', 'yarn', 'node', 'deno', 'go', 'cargo', 'make', 'just',
31 'python', 'python3', 'uv', 'pytest', 'swift', 'xcodebuild', 'xcrun', 'ruby', 'bundle', 'rake',
32 'mix', 'gradle', 'mvn', 'dotnet', 'php', 'composer', 'sh', 'bash', 'zsh', 'git', 'curl',
33 'docker', 'tsc', 'vitest', 'jest', 'playwright', 'sqlite3', 'jq', 'grep', 'rg', 'test',
34 'echo', 'cat', 'ls', 'diff', 'cmp', 'wc', 'timeout', 'env',
35])
36// Never run unattended: these change the world rather than check it.
37const NEVER = /\b(prod|production|deploy|release|publish|send|push|install|uninstall|rm|drop|delete|truncate|migrate|rollback|destroy|apply)\b/i
38// Never end on their own.
39const ENDLESS = new Set(['yes', 'watch', 'top', 'htop', 'less', 'more', 'vi', 'vim', 'nano', 'open'])
40// Words a commands-only gate uses around its commands; more than a few others means
41// the gate also asks for checks a command cannot prove.
42const GLUE = new Set(['exits', 'exit', 'and', 'prints', 'print', 'green', 'passes', 'pass', 'the', 'with', 'output', 'returns', 'shows', 'then', 'both', 'all'])
43
44const firstToken = (command: string): string =>
45 command.split(/\s+/).find(token => !/^[A-Z_][A-Z0-9_]*=/.test(token)) ?? ''
46
47const isCandidate = (span: string): boolean => {
48 if (!/\s/.test(span) || /<[^>]+>/.test(span) || span.includes('…') || span.startsWith('...')) return false
49 const token = firstToken(span)
50 return /^[\w./~-]+$/.test(token) && !ENDLESS.has(token) && !NEVER.test(span)
51}
52
53const gateText = (body: string): string => {
54 const at = body.indexOf('**Verifiable gate:**')
55 if (at < 0) return ''
56 const rest = body.slice(at + '**Verifiable gate:**'.length)
57 const next = rest.search(/^\*\*[A-Z][\w ]*:\*\*/m)
58 return (next < 0 ? rest : rest.slice(0, next)).trim()
59}
60
61const parsePhases = (plan: string): ParsedPhase[] => {
62 const heads = [...plan.matchAll(PHASE_HEAD)]
63 return heads.map((head, i) => {
64 const start = (head.index ?? 0) + head[0].length
65 const end = heads[i + 1]?.index ?? plan.length
66 const body = plan.slice(start, end).split(/^## /m)[0] ?? ''
67 const gate = gateText(body)
68 const candidates: string[] = []
69 for (const match of gate.matchAll(/`([^`\n]+)`/g)) {
70 const span = (match[1] ?? '').trim()
71 if (isCandidate(span) && !candidates.includes(span)) candidates.push(span)
72 }
73 const words = (gate.replace(/`[^`]*`/g, ' ').replace(/"[^"]*"/g, ' ').match(/[A-Za-z]{3,}/g) ?? [])
74 .filter(word => !GLUE.has(word.toLowerCase()))
75 return {
76 n: Number(head[1]),
77 title: (head[2] ?? '').trim(),
78 branch: /\*\*Branch:\*\*\s*`([^`]+)`/.exec(body)?.[1] ?? '',
79 gate,
80 candidates,
81 hasProse: words.length >= 6,
82 }
83 })
84}
85
86const onPath = new Map<string, boolean>()
87
88const isRunnable = async ($: EngineInterface, root: string, command: string): Promise<boolean> => {
89 const token = firstToken(command)
90 if (RUNNERS.has(token) || token.startsWith('./')) return true
91 const known = onPath.get(token)
92 if (known !== undefined) return known
93 const found = await $.process.run(['/bin/sh', '-c', 'command -v "$1" >/dev/null', 'sh', token], { cwd: root })
94 onPath.set(token, found.exitCode === 0)
95 return found.exitCode === 0
96}
97
98let cwd = ''
99let pinned: number | null = null
100
101// The directory the main loop's Bash runs in now; it can move during a session.
102const sessionDir = async ($: EngineInterface): Promise<string> => {
103 const dir = await $.session.cwd().catch(() => cwd)
104 return dir === '' ? cwd : dir
105}
106
107const roots = new Map<string, string | null>()
108
109const rootOf = async ($: EngineInterface, dir: string): Promise<string | null> => {
110 if (dir === '') return null
111 const known = roots.get(dir)
112 if (known !== undefined) return known
113 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
114 const root = top.exitCode === 0 ? top.stdout.trim() : null
115 // "No repo" isn't cached: `git init` can come later in the session.
116 if (root !== null) roots.set(dir, root)
117 return root
118}
119
120const readPlan = async ($: EngineInterface, root: string): Promise<string | null> => {
121 try {
122 return String(await $.fs.read(`${root}/wiki/plan.md`))
123 } catch {
124 return null
125 }
126}
127
128const runnableOf = async ($: EngineInterface, root: string, candidates: readonly string[]): Promise<string[]> => {
129 const commands: string[] = []
130 for (const command of candidates) {
131 if (!commands.includes(command) && (await isRunnable($, root, command))) commands.push(command)
132 }
133 return commands
134}
135
136// The phase numbered `number`, else the one whose branch is checked out in `dir`;
137// null outside a forge repo.
138const locate = async ($: EngineInterface, dir: string, number: number | null): Promise<GatePhase | null> => {
139 const root = await rootOf($, dir)
140 if (root === null) return null
141 const plan = await readPlan($, root)
142 if (plan === null) return null
143 const phases = parsePhases(plan)
144 let found: ParsedPhase | undefined
145 if (number !== null) {
146 found = phases.find(phase => phase.n === number)
147 } else {
148 const head = await $.process.run(['git', 'branch', '--show-current'], { cwd: root })
149 const branch = head.stdout.trim()
150 found = branch === '' ? undefined : phases.find(phase => phase.branch === branch)
151 }
152 if (found === undefined) return null
153 const commands = await runnableOf($, root, found.candidates)
154 return { n: found.n, title: found.title, branch: found.branch, gate: found.gate, commands, hasProse: found.hasProse, root }
155}
156
157let planCache: { root: string; mtimeMs: number; commands: string[] } | null = null
158
159// Every runnable gate command in the repo's plan, whichever branch is out: a run
160// counts before the branch matches (forge-build switches to its branch mid-turn,
161// Arnold works on its own branches).
162const planCommands = async ($: EngineInterface, root: string): Promise<string[]> => {
163 let mtimeMs: number
164 try {
165 mtimeMs = (await $.fs.stat(`${root}/wiki/plan.md`)).mtimeMs
166 } catch {
167 return []
168 }
169 if (planCache !== null && planCache.root === root && planCache.mtimeMs === mtimeMs) return planCache.commands
170 const plan = (await readPlan($, root)) ?? ''
171 const commands = await runnableOf($, root, parsePhases(plan).flatMap(phase => phase.candidates))
172 planCache = { root, mtimeMs, commands }
173 return commands
174}
175
176// ---------- The working tree ----------
177
178// The git tree id of every working file, untracked ones included, built in a
179// throwaway index so the real one is untouched. Content, not commits: the same
180// files give the same id before and after a commit, and any edit changes it.
181// wiki/ is left out: forge writes its build log and learnings after the gate runs.
182const TREE_SCRIPT = [
183 'idx=$(mktemp) || exit 1',
184 'cp "$(git rev-parse --git-path index)" "$idx" 2>/dev/null || rm -f "$idx"',
185 'GIT_INDEX_FILE="$idx" git add -A >/dev/null 2>&1 &&',
186 'GIT_INDEX_FILE="$idx" git rm -r -q --cached --ignore-unmatch -- wiki >/dev/null 2>&1 &&',
187 'GIT_INDEX_FILE="$idx" git write-tree',
188 'status=$?',
189 'rm -f "$idx"',
190 'exit $status',
191].join('\n')
192
193// '' when git fails: an unknown tree is never green.
194const treeId = async ($: EngineInterface, root: string): Promise<string> => {
195 const out = await $.process.run(['/bin/sh', '-c', TREE_SCRIPT], { cwd: root })
196 return out.exitCode === 0 ? out.stdout.trim() : ''
197}
198
199// Runs are kept per repo and command, so they don't depend on which phase is out.
200const runsKey = (root: string): string => `gate-runs:${root}`
201const checkedKey = (root: string, n: number): string => `gate-checked:${root}:${n}`
202
203// The repo whose runs the state holds.
204let loadedRoot = ''
205
206const adopt = async ($: EngineInterface, root: string): Promise<void> => {
207 if (root === loadedRoot) return
208 loadedRoot = root
209 const runs = ((await $.store.get(runsKey(root))) as Runs | undefined) ?? {}
210 await update($, runsAtom, () => runs)
211}
212
213const runsFor = async ($: EngineInterface, root: string): Promise<Runs> =>
214 root === loadedRoot ? read($, runsAtom) : (((await $.store.get(runsKey(root))) as Runs | undefined) ?? {})
215
216const recordRuns = async ($: EngineInterface, root: string, runs: readonly GateRun[]): Promise<void> => {
217 await adopt($, root)
218 await update($, runsAtom, all => ({ ...all, ...Object.fromEntries(runs.map(run => [run.command, run])) }))
219 await $.store.set(runsKey(root), await read($, runsAtom))
220}
221
222const refresh = async ($: EngineInterface): Promise<GatePhase | null> => {
223 const dir = await sessionDir($)
224 const root = await rootOf($, dir)
225 if (root !== null) await adopt($, root)
226 const phase = root === null ? null : await locate($, dir, pinned)
227 const before = await read($, phaseAtom)
228 if (JSON.stringify(before) !== JSON.stringify(phase)) {
229 const checked = phase === null ? null : (((await $.store.get(checkedKey(phase.root, phase.n))) as GateCheck | undefined) ?? null)
230 await update($, phaseAtom, () => phase)
231 await update($, checkedAtom, () => checked)
232 }
233 if (root !== null) {
234 const tree = await treeId($, root)
235 await update($, treeAtom, () => tree)
236 }
237 return phase
238}
239
240// ---------- Verdicts ----------
241
242const verdictOf = (phase: GatePhase, runs: Runs, checked: GateCheck | null, tree: string): Verdict => {
243 if (phase.commands.length === 0) {
244 const isCurrent = checked !== null && tree !== '' && checked.tree === tree && checked.gate === phase.gate
245 return isCurrent ? { kind: 'checked' } : { kind: 'manual' }
246 }
247 const current = phase.commands
248 .map(command => runs[command])
249 .filter((run): run is GateRun => run !== undefined && tree !== '' && run.tree === tree)
250 const failing = current.filter(run => !run.isOk)
251 if (failing.length > 0) return { kind: 'red', failing }
252 const missing = phase.commands.filter(command => !current.some(run => run.command === command))
253 return missing.length > 0 ? { kind: 'unrun', missing } : { kind: 'green' }
254}
255
256const tailOf = (text: string): string => {
257 const trimmed = text.trimEnd()
258 return trimmed.length > TAIL_CHARS ? `…${trimmed.slice(-TAIL_CHARS)}` : trimmed
259}
260
261const excerpt = (text: string, max = 240): string => {
262 const line = text.replace(/\s+/g, ' ').trim()
263 return line.length > max ? `${line.slice(0, max - 1)}…` : line
264}
265
266const ago = (ms: number): string => {
267 const seconds = Math.max(0, Math.round(ms / 1000))
268 if (seconds < 60) return `${seconds}s`
269 const minutes = Math.floor(seconds / 60)
270 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
271}
272
273const squash = (text: string): string => text.replace(/\s+/g, ' ').trim()
274
275const unquote = (word: string): string => word.replace(/^(['"])(.*)\1$/, '$2')
276
277const resolveDir = (dir: string, path: string): string => {
278 if (path.startsWith('/')) return path.replace(/\/+$/, '') || '/'
279 if (dir === '' || path === '' || path.startsWith('~') || path === '-') return ''
280 const parts = dir.split('/')
281 for (const part of path.split('/')) {
282 if (part === '..') parts.pop()
283 else if (part !== '.' && part !== '') parts.push(part)
284 }
285 return parts.join('/') || '/'
286}
287
288// Shell operators between commands. `2>&1` and `&>` are redirects, not operators.
289const OPERATOR = /(\s*(?:&&|\|\||;|\||(?<![<>&])&(?![&>])|\n)\s*)/
290const REDIRECT = /\s+(?:\d?>>?|&>>?|\d?<)\s*(?:&\d|[^\s|;&]+)/g
291
292// A segment as the command it runs: redirects, leading env assignments and a
293// `timeout <n>` wrapper removed.
294const commandOf = (segment: string): string =>
295 squash(segment.replace(REDIRECT, '')).replace(/^(?:[A-Za-z_]\w*=\S*\s+)*(?:timeout\s+\S+\s+)?/, '')
296
297// A failure is pinned on a gate command only when it is `isAlone`: last in the call,
298// with nothing but `cd` before it, so nothing else can have failed first.
299type GateHit = { command: string; isAlone: boolean }
300
301const partsOf = (bash: string): string[] => bash.trim().split(OPERATOR).map((part, k) => (k % 2 === 0 ? commandOf(part) : part.trim()))
302
303// The gate commands a Bash call really ran in the repo root, and whose exit code
304// the call's own status speaks for. A gate command (which may hold its own pipe)
305// must match whole segments; only `&&` or `;` may come before it (after `||`, `|` or
306// `&` it may not run, or not decide the status), and only `&&` after it. `base` is
307// the shell's directory, '' when unknown: then only an explicit `cd <root> &&` places it.
308const gateRunsIn = (bash: string, base: string, root: string, commands: readonly string[]): GateHit[] => {
309 const parts = partsOf(bash)
310 const wanted = commands.map(command => ({ command, parts: partsOf(command) }))
311 const hits: GateHit[] = []
312 let dir = base
313 let hasRunOther = false
314 for (let i = 0; i < parts.length; i += 2) {
315 const before = i === 0 ? '' : (parts[i - 1] ?? '')
316 if (before !== '' && before !== '&&' && before !== ';') break
317 const words = (parts[i] ?? '').split(/\s+/)
318 if (words[0] === 'cd') {
319 dir = resolveDir(dir, unquote(words[1] ?? ''))
320 continue
321 }
322 const match = wanted.find(gate => gate.parts.every((part, k) => parts[i + k] === part))
323 if (match !== undefined && dir === root) {
324 const end = i + match.parts.length
325 const after = parts.slice(end).filter((_, k) => k % 2 === 0)
326 if (after.every(op => op === '&&')) hits.push({ command: match.command, isAlone: after.length === 0 && !hasRunOther })
327 }
328 hasRunOther = true
329 }
330 return hits
331}
332
333const VERDICT_WORD: Record<Verdict['kind'], string> = {
334 green: 'green',
335 red: 'red',
336 unrun: 'not green',
337 manual: 'written checks only, not checked',
338 checked: 'checked by the person',
339}
340
341const report = (phase: GatePhase, runs: Runs, checked: GateCheck | null, tree: string): string => {
342 const verdict = verdictOf(phase, runs, checked, tree)
343 const lines = [`Phase ${phase.n} — ${phase.title}: gate ${VERDICT_WORD[verdict.kind]} on this tree.`]
344 for (const command of phase.commands) {
345 const run = runs[command]
346 if (run === undefined) lines.push(`· \`${command}\` has not run.`)
347 else if (tree === '' || run.tree !== tree) lines.push(`· \`${command}\` has not run since the last change.`)
348 else if (run.isOk) lines.push(`✓ \`${command}\` passed${run.exitCode !== undefined ? ` (exit ${run.exitCode})` : ''}.`)
349 else lines.push(`✗ \`${command}\` failed${run.exitCode !== undefined ? ` (exit ${run.exitCode})` : ''}: ${excerpt(run.tail, 300)}`)
350 }
351 if (phase.commands.length === 0) lines.push(`The gate has no command to run. Its checks: ${excerpt(phase.gate)}`)
352 else if (phase.hasProse) lines.push(`The gate also asks for checks no command proves: ${excerpt(phase.gate)}`)
353 return lines.join('\n')
354}
355
356// ---------- Running the gate ----------
357
358const approved = new Set<string>()
359
360const runGate = async ($: EngineInterface): Promise<string> => {
361 const phase = await refresh($)
362 if (phase === null) {
363 return 'forge-gate: no forge phase here. Check out a phase branch named in wiki/plan.md, or pin one with /gate <n>.'
364 }
365 if (phase.commands.length === 0) {
366 const [checked, tree] = await Promise.all([read($, checkedAtom), read($, treeAtom)])
367 return report(phase, {}, checked, tree)
368 }
369 const key = `${phase.root}\n${phase.commands.join('\n')}`
370 if (!approved.has(key)) {
371 // Rejects when the person dismisses it, and under -p where nobody can answer.
372 const answer = await $.ui
373 .ask(`Run phase ${phase.n}'s gate in ${phase.root}?\n${phase.commands.map(command => ` ${command}`).join('\n')}`, [
374 'Run',
375 'Cancel',
376 ])
377 .catch(() => 'Cancel')
378 if (answer !== 'Run') return 'forge-gate: gate not run.'
379 approved.add(key)
380 }
381
382 // Pinned to the files the commands saw: an edit made while they run is not credited.
383 const before = await treeId($, phase.root)
384 await update($, runningAtom, () => true)
385 const done: GateRun[] = []
386 try {
387 for (const command of phase.commands) {
388 const started = await $.clock.now()
389 try {
390 const out = await $.process.run(['/bin/sh', '-c', command], { cwd: phase.root, timeoutMs: RUN_TIMEOUT_MS })
391 const at = await $.clock.now()
392 done.push({ command, isOk: out.exitCode === 0, exitCode: out.exitCode, at, ms: at - started, tail: tailOf(`${out.stdout}\n${out.stderr}`), tree: before, by: 'person' })
393 } catch (error) {
394 const at = await $.clock.now()
395 done.push({ command, isOk: false, at, ms: at - started, tail: String(error), tree: before, by: 'person' })
396 }
397 }
398 } finally {
399 await update($, runningAtom, () => false)
400 }
401 await recordRuns($, phase.root, done)
402 const tree = await treeId($, phase.root)
403 await update($, treeAtom, () => tree)
404 const [runs, checked] = await Promise.all([read($, runsAtom), read($, checkedAtom)])
405 return report(phase, runs, checked, tree)
406}
407
408// A press of the band's Run gate: the result's first line as a toast.
409const announce = async ($: EngineInterface): Promise<void> => {
410 const text = await runGate($)
411 $.ui.toast(text.split('\n')[0] ?? '')
412}
413
414const markChecked = async ($: EngineInterface): Promise<void> => {
415 const phase = await read($, phaseAtom)
416 if (phase === null) return
417 const [tree, at] = await Promise.all([treeId($, phase.root), $.clock.now()])
418 await update($, treeAtom, () => tree)
419 await update($, checkedAtom, () => ({ at, tree, gate: phase.gate }))
420 await $.store.set(checkedKey(phase.root, phase.n), { at, tree, gate: phase.gate })
421}
422
423// ---------- Hooks ----------
424
425// Phrases that report a phase as finished. The guard acts only on these, so a
426// turn that pauses to ask something is never sent back.
427const DONE_CLAIMS = [
428 /\bphase\s*\d*\s*(is\s+)?(now\s+)?(done|complete|completed|finished|built|ready)\b/i,
429 /\b(built|finished|completed|implemented)\s+(the\s+)?phase\s*\d+/i,
430 /\b(ready|good)\s+to\s+(ship|merge|land)\b/i,
431 /\bready\s+for\s+(review|ship|merge|forge-review|forge-ship)\b/i,
432 /\bgate\s+(is\s+)?(green|passes|passed|passing|met|satisfied|holds)\b/i,
433 /\bhand(ing)?[\s-]?off\s+to\s+forge-(review|ship)\b/i,
434 /\ball\s+(checks|tests|gates?)\s+(pass|passed|passing|are\s+green|green)\b/i,
435]
436
437// A claim stated as a sentence, not asked: "Is this ready for review?" waits for the
438// person, while "Phase 2 is done. Want me to ship it?" still reports done.
439const claimsDone = (text: string): boolean =>
440 text
441 .split(/(?<=[.!?])\s+|\n+/)
442 .some(sentence => !sentence.trim().endsWith('?') && DONE_CLAIMS.some(pattern => pattern.test(sentence)))
443
444type ForgeResult = { skill?: unknown; status?: unknown; phase?: unknown; gate?: unknown }
445
446// The FORGE_RESULT line every forge stage ends with (forge/references/headless.md).
447// When a message has several, the last one counts.
448const forgeResultIn = (text: string): ForgeResult | undefined => {
449 const lines = [...text.matchAll(/^FORGE_RESULT[ \t]+(\{.*\})[ \t]*$/gm)]
450 const json = lines[lines.length - 1]?.[1]
451 if (json === undefined) return undefined
452 try {
453 const parsed: unknown = JSON.parse(json)
454 return typeof parsed === 'object' && parsed !== null ? (parsed as ForgeResult) : undefined
455 } catch {
456 return undefined
457 }
458}
459
460// Checks a result line's "gate":"green" against what actually ran on the current
461// files. The phase is the one the line names, so forge-ship's claim is still checked
462// after it lands on the base branch. Undefined when there is nothing to hold: no
463// green claim, no such phase, or a gate that no command proves.
464const claimReason = async ($: EngineInterface, dir: string, result: ForgeResult): Promise<string | undefined> => {
465 if (result.status !== 'done' || result.gate !== 'green') return undefined
466 const current = await read($, phaseAtom)
467 const n = typeof result.phase === 'number' ? result.phase : (current?.n ?? null)
468 if (n === null) return undefined
469 const phase = await locate($, dir, n)
470 if (phase === null || phase.commands.length === 0) return undefined
471 const runs = await runsFor($, phase.root)
472 const tree = await treeId($, phase.root)
473 if (verdictOf(phase, runs, null, tree).kind === 'green') return undefined
474 const whose = typeof result.skill === 'string' ? `${result.skill}'s` : 'your'
475 return [
476 `forge-gate: ${whose} FORGE_RESULT says phase ${n}'s gate is green, but it is not green on the current files.`,
477 report(phase, runs, null, tree),
478 'Run each gate command exactly as written, from the repo root, with nothing piped after it (no `| tail`), so its exit code counts. Then end with the result line again. If it will not go green, set "gate" to "red" (or "deferred" when it cannot run here) and say why in "notes".',
479 ].join('\n')
480}
481
482const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
483
484export const register: Register = (on, options) => {
485 const isGuarding = options['guardDoneClaims'] !== false
486
487 on('session.start', async ($, e, next) => {
488 cwd = e.cwd
489 await $.command.register({
490 name: 'gate',
491 description: "Run the forge phase's Verifiable gate (/gate status, /gate <n> to pin a phase, /gate auto)",
492 })
493 await refresh($).catch(() => null)
494 return next(e)
495 })
496
497 on('command.run', { command: 'gate' }, async ($, e) => {
498 const arg = e.args.trim()
499 if (/^\d+$/.test(arg) || arg === 'auto' || arg === 'status') {
500 if (arg === 'auto') pinned = null
501 else if (arg !== 'status') pinned = Number(arg)
502 const phase = await refresh($)
503 if (phase === null) return { text: 'forge-gate: no forge phase matches. Check the branch or the phase number in wiki/plan.md.' }
504 const [runs, checked, tree] = await Promise.all([read($, runsAtom), read($, checkedAtom), read($, treeAtom)])
505 return { text: `${report(phase, runs, checked, tree)}\nCommands: ${phase.commands.length === 0 ? 'none' : phase.commands.map(c => `\`${c}\``).join(', ')}` }
506 }
507 return { text: await runGate($) }
508 })
509
510 on('tool.call', async ($, e, next) => {
511 if (e.tool !== 'Bash') {
512 const ran = await next(e)
513 if (EDIT_TOOLS.has(String(e.tool))) await update($, treeAtom, () => '')
514 return ran
515 }
516
517 // A subagent's shell directory is unknown, so only an explicit `cd <root> &&` counts.
518 const base = e.agentId === undefined ? await sessionDir($) : ''
519 const root = await rootOf($, base === '' ? await sessionDir($) : base)
520 const hits = root === null || e.run_in_background === true ? [] : gateRunsIn(e.command, base, root, await planCommands($, root))
521 if (root === null || hits.length === 0) {
522 const ran = await next(e)
523 if (ran.isReadOnly !== true) await update($, treeAtom, () => '')
524 return ran
525 }
526
527 // Pinned to the files the command saw: an edit made while it runs is not credited.
528 const before = await treeId($, root)
529 const ran = await next(e)
530 const output = ran.deny === undefined && ran.isError !== true ? ran.result : undefined
531 // Bash moves a long command to the background on a timeout or Ctrl+B: no exit code yet.
532 const isUnfinished = output !== undefined && (output.backgroundTaskId !== undefined || output.interrupted)
533 // Recorded even when the fingerprint failed: a run on an unknown tree ('') is
534 // never green, and it replaces any older pass for the same command.
535 if (ran.deny === undefined && !isUnfinished) {
536 const isOk = ran.isError !== true
537 const at = await $.clock.now()
538 const runs = hits
539 .filter(hit => isOk || hit.isAlone)
540 .map(hit => ({ command: hit.command, isOk, at, tail: tailOf(ran.text ?? ''), tree: before, by: 'claude' as const }))
541 await recordRuns($, root, runs)
542 }
543 const tree = await treeId($, root)
544 await update($, treeAtom, () => tree)
545 return ran
546 })
547
548 on('turn.complete', async ($, e, next) => {
549 if (e.agentId === undefined) await refresh($).catch(() => null)
550 return next(e)
551 })
552
553 on('classic.Stop', async ($, e, next) => {
554 const result = await next(e)
555 if (!isGuarding || result.block !== undefined || e.stop_hook_active) return result
556 const text = e.last_assistant_message ?? ''
557 // A forge stage's result line is the exact claim: check it, and skip the phrases.
558 const forgeResult = forgeResultIn(text)
559 if (forgeResult !== undefined) {
560 const reason = await claimReason($, e.cwd || cwd, forgeResult)
561 return reason === undefined ? result : { ...result, block: reason }
562 }
563 if (!claimsDone(text)) return result
564 const phase = await refresh($)
565 if (phase === null) return result
566 const [runs, checked, tree] = await Promise.all([read($, runsAtom), read($, checkedAtom), read($, treeAtom)])
567 const verdict = verdictOf(phase, runs, checked, tree)
568 if (verdict.kind === 'green' || verdict.kind === 'checked') return result
569
570 const ask =
571 verdict.kind === 'manual'
572 ? 'Its checks need a person: ask them to check it and press Checked in the gate band. Do not call the phase done before that.'
573 : 'Run each gate command exactly as written, from the repo root, with nothing piped after it (no `| tail`), so its exit code counts. Fix what fails, or say plainly that the phase is not done yet.'
574 return {
575 ...result,
576 block: `forge-gate: you reported phase ${phase.n} as done, but its Verifiable gate is not green on the current working tree.\n${report(phase, runs, checked, tree)}\n${ask}`,
577 }
578 })
579
580 // A forge stage run as a subagent ends with its result line too.
581 on('classic.SubagentStop', async ($, e, next) => {
582 const result = await next(e)
583 if (!isGuarding || result.block !== undefined || e.stop_hook_active) return result
584 const forgeResult = forgeResultIn(e.last_assistant_message ?? '')
585 if (forgeResult === undefined) return result
586 const reason = await claimReason($, e.cwd || cwd, forgeResult)
587 return reason === undefined ? result : { ...result, block: reason }
588 })
589
590 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
591 const phase = await read($, phaseAtom)
592 if (phase === null || e.props.hasSurvey) return next(e)
593
594 const [runs, checked, tree, isRunning, at] = await Promise.all([
595 read($, runsAtom),
596 read($, checkedAtom),
597 read($, treeAtom),
598 read($, runningAtom),
599 $.clock.now(),
600 ])
601 const verdict = verdictOf(phase, runs, checked, tree)
602 const latest = Math.max(0, ...Object.values(runs).map(run => run.at))
603 const { Box, Text, Button } = $.ui.resolve(e)
604
605 const state = isRunning
606 ? { color: 'cyan', word: '… running the gate' }
607 : verdict.kind === 'green'
608 ? { color: 'green', word: `✓ gate green ${ago(at - latest)} ago` }
609 : verdict.kind === 'checked'
610 ? { color: 'green', word: '✓ checked by you' }
611 : verdict.kind === 'red'
612 ? { color: 'red', word: `✗ gate red: ${verdict.failing.map(run => run.command).join(', ')}` }
613 : verdict.kind === 'manual'
614 ? { color: 'yellow', word: '◐ written checks, not checked' }
615 : {
616 color: 'yellow',
617 word:
618 tree === '' && latest > 0
619 ? '◐ changed since the last run'
620 : `◐ ${verdict.kind === 'unrun' ? verdict.missing.length : phase.commands.length} of ${phase.commands.length} not run on this tree`,
621 }
622
623 const below = await next(e)
624 return (
625 <Box flexDirection="column">
626 <Box key="forge-gate" flexDirection="row" gap={1} width={e.props.bodyColumns}>
627 <Text dimColor wrap="truncate-end">
628 Phase {phase.n} · {phase.title}
629 </Text>
630 <Text color={state.color} wrap="truncate-end">
631 {state.word}
632 </Text>
633 {phase.hasProse && phase.commands.length > 0 && <Text dimColor>+ written checks</Text>}
634 {!isRunning && phase.commands.length > 0 && (
635 <Button key="run-gate" label="Run gate" onPress={() => void announce($)} />
636 )}
637 {phase.commands.length === 0 && verdict.kind !== 'checked' && (
638 <Button key="mark-checked" label="Checked" onPress={() => void markChecked($)} />
639 )}
640 </Box>
641 {below}
642 </Box>
643 )
644 })
645}
646types/index.d.ts 51 lines1// forge-gate's state contract: the phase in force and the evidence for its gate,
2// held by the host in $.state so a hot reload keeps it.
3
4/** The forge phase whose branch is checked out, read from wiki/plan.md. */
5export type GatePhase = {
6 n: number
7 title: string
8 branch: string
9 /** The Verifiable gate's full text. */
10 gate: string
11 /** The gate's backticked commands that are safe to run unattended. */
12 commands: string[]
13 /** True when the gate asks for more than its commands prove. */
14 hasProse: boolean
15 root: string
16}
17
18/** One run of one gate command, and the working tree it ran on. */
19export type GateRun = {
20 command: string
21 isOk: boolean
22 exitCode?: number
23 at: number
24 ms?: number
25 tail: string
26 /** The git tree id of all working files (untracked included) when it ran. */
27 tree: string
28 by: 'claude' | 'person'
29}
30
31/** The person saying a commandless gate's written checks hold on this tree. */
32export type GateCheck = {
33 at: number
34 tree: string
35 /** The gate text it was checked against; editing the gate voids it. */
36 gate: string
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'forge-gate': {
42 phase: GatePhase | null
43 runs: Record<string, GateRun>
44 checked: GateCheck | null
45 /** The tree's fingerprint now; '' while an edit may have changed it. */
46 tree: string
47 isRunning: boolean
48 }
49 }
50}
51