Flags a turn that wrote a lot and offers a /guide reading order; before Claude commits, measures the change's blast radius and asks Jev about risk and…

It is a mod rather than a skill because it has to intercept the commit, and its pane, live Codex progress and approval buttons are the workflow.
Before a commit Claude makes, it scores the change and gives one of three verdicts:
The verdict comes from the blast radius (lines, files, areas, how many files use the changed one, build and schema files) and, with a TypeSafe key, from Jev's judgements (security, data, money, concurrency, public API, readability). Consequence is judged apart from size: a change to sensitive paths (auth, session, token, secret, permission, .env and similar) is never a pass, and a likely security change is held whatever its size.
"Used by" counts the files that import the changed one: a quoted path in TypeScript or JavaScript (a relative one must resolve to the file, so a ./pane next to another pane.tsx does not count), import/from in Python, #include in C and C++, mod/use in Rust, require in Ruby. C#, Java, Kotlin, Scala, Swift and PHP files match the class name as a word. Go files and generic names (index, utils, types ...) are not counted.
The Triage pane leads with the verdict and what it means, a one-line summary of the change (files, lines added and removed, areas), and what the assessment rests on: the whole diff with Jev, part of it, or local checks only, with the reason Jev was not used. Then come Why, the concrete reasons, and Start with, up to five files to inspect first, each with its reason (sensitive code, a file Jev points at with its signals by name, build or schema, widely used, biggest change), code before its tests at each step. If the change is edited after the assessment, the pane says it is stale, hides Approve commit and makes Refresh assessment the main button. Detailed signals is folded by default and shows only the Jev signals that fired, each as likely or possible with what it measures and where to look: the file and line Jev picks, or for a mixed change the groups it could be split into. A behaviour change counts only when no tests in the diff cover it, since nearly every diff changes behaviour. What Jev checked and did not flag fits on one line, then a diagnostics line (who judged, how long Jev took, how many tokens it read, when it ran). Percentages are left to /gate details. The API key is never shown.
Jev picks the place in the same request. Each hunk of the diff Jev reads is tagged H01|, H02| ... and each risk signal and readability gets a choice among the tags, in diff order (Jev leans toward the first option, so size or risk order would bias it). A file is named when its hunks hold at least 0.4 of the weight together, and the line of its top hunk when that hunk holds more than half of the file's share. A one-hunk change needs no question. TypeSafe bills input only ($0.042 per million tokens); the tags and questions added about 3k tokens to an 18.8k-token diff, so a commit costs about a tenth of a cent. On a focused diff the places were exact; on a wide, mixed one Jev mostly names none, and the mixed-change flag is the useful line there.
Commands (each works without the pane):
/gate triages the uncommitted change and opens the Triage pane. The pane's Refresh assessment does the same./gate details prints the pane's content as text: reasons, files to start with, every Jev signal with its percentage and file, and diagnostics./gate approve (or the pane's Approve commit) approves the last triaged change and puts the request to commit in the prompt box, after anything already typed there; press Enter to send it. When the hold came from Claude's commit, the request repeats that exact command; after a plain /gate, it asks Claude to commit the change. It first checks the change against the assessment: if anything was edited since, it approves nothing, marks the assessment stale and asks you to run /gate again. The pane approves only the assessment it is showing. An approval covers the same files with the same content however the commit is made, so approving the working tree with new files still holds once those files are staged./gate codex [focus] (or the pane's Run Codex review) runs a Codex review of at most 15 changed files: flagged ones first, then the largest. Findings show in the pane with a "Send to Claude" button. The review is marked stale when Claude edits a file with Edit or Write, or a later triage sees a different change. Edits by hand, by shell commands or by other tools are not noticed: run /gate after them./guide puts a reading-guide request for the turn's files in the prompt box.At the end of a turn it also raises a toast when the uncommitted change grows past a size level, once per level.
Setup:
claude plugin configure triage-gate@my-mods
The typesafeApiKey option is secret; TYPESAFE_API_KEY is the fallback. Without either, triage uses the blast radius alone. codexModel picks the Codex review model.
Note: with a key configured, each triage (a commit Claude makes, /gate, or "Triage again") sends the changed file paths and up to 60k characters of the diff to TypeSafe (api.typesafe.ai). A Codex review runs only on request and is billed to your Codex account.
hooks/register.tsx 559 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { ChangedFile, Jev, Review, Scope, Subject, Triage } from '../types'
5import { blastOf, countUsers, lineCount, parseNumstat, parseRaw, usersQuery } from './blast'
6import { addWrite, emptyTally, guidePrompt, sizeLevel, sizeNote } from './guide'
7import { JEV_URL, PATCH_CHARS, askOf, parseJev } from './jev'
8import { paneOf } from './pane'
9import { commitPrompt, detailsText, reviewHeadline, subjectKey, summaryOf } from './report'
10import { BRIEF_CHARS, BRIEF_PROMPT, addAsk, lastLine, parseReview, reviewPrompt, reviewScope } from './review'
11import { commitIn } from './shell'
12import { verdictOf } from './verdict'
13
14const PANE = 'triage-gate'
15const TITLE = 'Triage'
16const UNTRACKED_READ = 30
17const UNTRACKED_CHARS = 4000
18const FAN_IN_FILES = 15
19const REVIEW_CHARS = 20_000
20const REVIEW_TIMEOUT_MS = 600_000
21const TIMED_OUT = 'timed out'
22const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
23
24const triage = atom({ plugin: 'triage-gate', key: 'triage' } as const, null)
25const approved = atom({ plugin: 'triage-gate', key: 'approved' } as const, [])
26const review = atom({ plugin: 'triage-gate', key: 'review' } as const, { status: 'idle' })
27const turn = atom({ plugin: 'triage-gate', key: 'turn' } as const, emptyTally())
28const sizeSeen = atom({ plugin: 'triage-gate', key: 'sizeLevel' } as const, 0)
29const asks = atom({ plugin: 'triage-gate', key: 'asks' } as const, [])
30const details = atom({ plugin: 'triage-gate', key: 'details' } as const, false)
31
32type Where = { cwd?: string; stdin?: string }
33
34async function git($: EngineInterface, args: string[], where: Where = {}) {
35 const ran = await $.process.run(['git', ...args], where).catch(() => undefined)
36
37 return ran?.exitCode === 0 ? ran.stdout : undefined
38}
39
40const inside = (cwd: string | undefined) => (cwd === undefined ? {} : { cwd })
41
42async function hexOf(text: string) {
43 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
44
45 return [...new Uint8Array(digest)]
46 .slice(0, 8)
47 .map(byte => byte.toString(16).padStart(2, '0'))
48 .join('')
49}
50
51const pathspec = (paths: string[] | undefined) => (paths === undefined ? [] : ['--', ...paths])
52
53async function untrackedFiles($: EngineInterface, cwd?: string, within?: string[]) {
54 const listed = (await git($, ['ls-files', '--others', '--exclude-standard', ...pathspec(within)], inside(cwd))) ?? ''
55 const paths = listed.split(/\r?\n/).filter(Boolean)
56
57 return Promise.all(
58 paths.map(async (path, at) => {
59 const from = cwd === undefined ? path : `${cwd}/${path}`
60 const text = at < UNTRACKED_READ ? await $.fs.read(from).catch(() => '') : ''
61 const file: ChangedFile = { path, added: lineCount(text), removed: 0, isNew: true }
62
63 return { file, text }
64 }),
65 )
66}
67
68type Untracked = Awaited<ReturnType<typeof untrackedFiles>>
69
70/**
71 * The paths and blobs the commit would take, so a change gets one fingerprint however it was judged: as a working
72 * tree with new files, or after they were staged. Falls back to the diff text when git cannot hash the files.
73 */
74async function fingerprintOf($: EngineInterface, range: string, limit: string[], untracked: Untracked, cwd?: string) {
75 const raw = await git($, ['diff', range, '--raw', '-z', '--no-abbrev', '--no-renames', ...limit], inside(cwd))
76 const [prefix = '', up = ''] = ((await git($, ['rev-parse', '--show-prefix', '--show-cdup'], inside(cwd))) ?? '').split(/\r?\n/)
77 const changed = [
78 ...parseRaw(raw ?? '').map(({ path, blob }) => ({ path, from: `${up}${path}`, blob })),
79 ...untracked.map(({ file }) => ({ path: `${prefix}${file.path}`, from: file.path, blob: undefined })),
80 ]
81 const unhashed = changed.filter(({ blob }) => blob === undefined).map(({ from }) => from)
82 const hashed =
83 unhashed.length === 0 ? '' : await git($, ['hash-object', '--stdin-paths'], { ...inside(cwd), stdin: unhashed.join('\n') })
84
85 if (raw === undefined || hashed === undefined) {
86 const diff = (await git($, ['diff', range, ...limit], inside(cwd))) ?? ''
87
88 return hexOf([diff, ...untracked.map(({ file, text }) => `${file.path}\n${text}`)].join('\n'))
89 }
90
91 const blobs = hashed.split(/\r?\n/)
92 let next = 0
93 const lines = changed.map(({ path, blob }) => `${path}\t${blob === undefined ? blobs[next++] : (blob ?? '-')}`)
94
95 return hexOf(lines.sort().join('\n'))
96}
97
98/** The fingerprint of the change `subject` names as it stands now, without judging it; `undefined` when git failed. */
99async function fingerprintNow($: EngineInterface, subject?: Subject) {
100 const scope = subject?.scope ?? 'tree'
101 const { range, limit, paths, numstat } = await changedSince($, scope, subject?.cwd, subject?.paths)
102
103 if (numstat === undefined) {
104 return undefined
105 }
106
107 const untracked = scope === 'tree' ? await untrackedFiles($, subject?.cwd, paths) : []
108
109 return fingerprintOf($, range, limit, untracked, subject?.cwd)
110}
111
112async function usersOf($: EngineInterface, file: ChangedFile, cwd?: string): Promise<ChangedFile> {
113 const query = usersQuery(file.path)
114
115 if (query === undefined || file.isNew) {
116 return file
117 }
118
119 const found = (await git($, ['grep', '--full-name', ...query], inside(cwd))) ?? ''
120
121 return { ...file, usedBy: countUsers(found, file.path) }
122}
123
124async function jevOf($: EngineInterface, key: string | undefined, { body, hunks }: ReturnType<typeof askOf>): Promise<Jev> {
125 if (key === undefined || key === '') {
126 return { status: 'off' }
127 }
128
129 const startedAt = Date.now()
130
131 try {
132 const answered = await $.http.fetch(JEV_URL, {
133 method: 'POST',
134 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
135 body,
136 })
137
138 return { ...parseJev(answered.status, answered.text, hunks), tookMs: Date.now() - startedAt }
139 } catch {
140 return { status: 'failed', note: 'Jev could not be reached; triage used blast radius only.', tookMs: Date.now() - startedAt }
141 }
142}
143
144/** Already staged files, from the top of the repo (`:/`) since `cwd` may be a subfolder; `undefined` when git failed. */
145async function stagedPaths($: EngineInterface, cwd?: string) {
146 const listed = await git($, ['diff', '--cached', '--name-only', '-z'], inside(cwd))
147
148 return listed
149 ?.split('\0')
150 .filter(Boolean)
151 .map(path => `:/${path}`)
152}
153
154/**
155 * With `added` paths, the change is what was staged plus those paths against HEAD. A staged file's unstaged edits are
156 * counted too, which errs towards judging more.
157 */
158async function changedSince($: EngineInterface, scope: Scope, cwd?: string, added?: string[]) {
159 const first = scope === 'staged' ? '--cached' : 'HEAD'
160 const staged = added === undefined ? undefined : await stagedPaths($, cwd)
161 const limit = added === undefined || staged === undefined ? [] : pathspec([...staged, ...added])
162 const paths = limit.length === 0 ? undefined : added
163 const numstat = await git($, ['diff', first, '--numstat', ...limit], inside(cwd))
164
165 if (numstat !== undefined || scope === 'staged') {
166 return { range: first, limit, paths, numstat }
167 }
168
169 return { range: EMPTY_TREE, limit, paths, numstat: await git($, ['diff', EMPTY_TREE, '--numstat', ...limit], inside(cwd)) }
170}
171
172async function runTriage(
173 $: EngineInterface,
174 options: PluginOptions,
175 scope: Scope = 'tree',
176 cwd?: string,
177 added?: string[],
178 command?: string,
179): Promise<Triage | null> {
180 const { range, limit, paths, numstat } = await changedSince($, scope, cwd, added)
181
182 if (numstat === undefined) {
183 return null
184 }
185
186 const untracked = scope === 'tree' ? await untrackedFiles($, cwd, paths) : []
187 const tracked = parseNumstat(numstat)
188
189 if (tracked.length === 0 && untracked.length === 0) {
190 await update($, triage, () => null)
191
192 return null
193 }
194
195 const diff = (await git($, ['diff', range, ...limit], inside(cwd))) ?? ''
196 const fullPatch = [
197 diff,
198 ...untracked.map(({ file, text }) => `+++ new file ${file.path}\n${text.slice(0, UNTRACKED_CHARS)}`),
199 ].join('\n')
200 const patch = fullPatch.slice(0, PATCH_CHARS)
201 const files = await Promise.all(
202 [...tracked, ...untracked.map(({ file }) => file)].map((file, at) =>
203 at < FAN_IN_FILES ? usersOf($, file, cwd) : file,
204 ),
205 )
206 const blast = blastOf(files)
207 const configured = options.typesafeApiKey
208 const key =
209 typeof configured === 'string' && configured !== ''
210 ? configured
211 : await $.env.get('TYPESAFE_API_KEY')
212 const jev = await jevOf($, key, askOf(blast, patch))
213 const subject: Subject = { scope, ...(cwd === undefined ? {} : { cwd }), ...(added === undefined ? {} : { paths: added }) }
214 const judged: Triage = {
215 subject,
216 fingerprint: await fingerprintOf($, range, limit, untracked, cwd),
217 ...verdictOf(blast, jev),
218 blast,
219 jev,
220 isPatchCut: fullPatch.length > PATCH_CHARS,
221 assessedAt: Date.now(),
222 ...(command === undefined ? {} : { command }),
223 }
224
225 await update($, triage, () => judged)
226
227 return judged
228}
229
230async function briefOf($: EngineInterface) {
231 const reply = await $.model.fork({ prompt: BRIEF_PROMPT }).catch(() => undefined)
232
233 return reply?.isAnswered === true ? reply.text.trim().slice(0, BRIEF_CHARS) : undefined
234}
235
236/** Puts `text` in the prompt box after whatever the user has typed there, never over it. */
237async function fillKeepingDraft($: EngineInterface, text: string) {
238 const draft = (await $.prompt.read()).text
239
240 return draft.trim() === ''
241 ? $.prompt.fill({ text, mode: 'replace' })
242 : $.prompt.fill({ text: `
243
244${text}`, mode: 'append' })
245}
246
247async function markEdited($: EngineInterface) {
248 await update($, review, held => (held.status === 'done' && held.isEdited !== true ? { ...held, isEdited: true } : held))
249 await update($, triage, held => (held !== null && held.isEdited !== true ? { ...held, isEdited: true } : held))
250}
251
252async function runCodex($: EngineInterface, options: PluginOptions, focus = '') {
253 const startedAt = Date.now()
254 const judged = await read($, triage)
255 const fingerprint = judged?.fingerprint
256 const subject = subjectKey(judged?.subject)
257 const covers = reviewScope(judged, focus).label
258 await update($, review, (): Review => ({ status: 'running', covers, tookMs: 0, doing: 'Writing down what was asked for…' }))
259 const brief = await briefOf($)
260 const told = brief === undefined ? {} : { brief }
261 const intent = { asks: await read($, asks), ...told }
262 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
263 const model = typeof options.codexModel === 'string' && options.codexModel !== '' ? ['-m', options.codexModel] : []
264 const codex = ['codex', 'exec', 'review', ...model, '-']
265 const said = { stdout: '', stderr: '' }
266 let exit: number | null | undefined
267 let isTimedOut = false
268 const deadline = $.clock.sleep(REVIEW_TIMEOUT_MS).then(
269 (): typeof TIMED_OUT => TIMED_OUT,
270 () => new Promise<never>(() => undefined),
271 )
272
273 try {
274 const child = $.process.spawn({
275 argv: isWindows ? ['cmd.exe', '/d', '/c', ...codex] : codex,
276 input: reviewPrompt(judged, focus, intent),
277 })
278
279 for (;;) {
280 const pulled = await Promise.race([child.next(), deadline])
281
282 if (pulled === TIMED_OUT) {
283 isTimedOut = true
284 void child.return({ code: null, signal: null }).catch(() => undefined)
285 break
286 }
287
288 if (pulled.done === true) break
289
290 const piece = pulled.value
291 said[piece.stream] += piece.text
292 const tookMs = Date.now() - startedAt
293 const doing = lastLine(piece.text)
294 await update($, review, (held): Review => ({
295 status: 'running',
296 covers,
297 ...told,
298 tookMs,
299 ...(doing !== undefined ? { doing } : held.status === 'running' && held.doing !== undefined ? { doing: held.doing } : {}),
300 }))
301 }
302
303 exit = isTimedOut ? null : (await child.result.catch(() => ({ code: null }))).code
304 } catch {
305 exit = undefined
306 }
307
308 const text =
309 exit === undefined
310 ? 'Codex could not be started. Is the codex CLI installed and logged in?'
311 : isTimedOut
312 ? `Codex was stopped after ${REVIEW_TIMEOUT_MS / 60_000} minutes. ${lastLine(said.stdout) ?? lastLine(said.stderr) ?? ''}`.trim()
313 : (said.stdout.trim() || said.stderr.trim() || 'Codex printed nothing.').slice(-REVIEW_CHARS)
314 const done: Review = {
315 status: 'done',
316 covers,
317 ...told,
318 ...parseReview(text),
319 tookMs: Date.now() - startedAt,
320 isClean: exit === 0,
321 ...(isTimedOut ? { isTimedOut } : {}),
322 ...(fingerprint === undefined ? {} : { fingerprint }),
323 ...(subject === undefined ? {} : { subject }),
324 }
325 await update($, review, () => done)
326
327 return done
328}
329
330async function noteSize($: EngineInterface) {
331 const { numstat } = await changedSince($, 'tree')
332
333 if (numstat === undefined) {
334 return
335 }
336
337 const untracked = await untrackedFiles($)
338 const blast = blastOf([...parseNumstat(numstat), ...untracked.map(({ file }) => file)])
339 const level = sizeLevel(blast.lines)
340 const seen = await read($, sizeSeen)
341
342 if (level > seen) {
343 $.ui.toast(sizeNote(blast.lines, blast.files.length), { timeoutMs: 12000 })
344 }
345
346 if (level !== seen) {
347 await update($, sizeSeen, () => level)
348 }
349}
350
351/**
352 * Approves the current assessment if the change still matches it, else marks it stale. `drawn` is the fingerprint
353 * the pane showed; a newer assessment since then is left for the user to see first.
354 */
355async function approve($: EngineInterface, drawn?: string) {
356 const judged = await read($, triage)
357
358 if (judged === null || (drawn !== undefined && drawn !== judged.fingerprint)) {
359 return 'missing'
360 }
361
362 const now = await fingerprintNow($, judged.subject)
363
364 if (now !== undefined && now !== judged.fingerprint) {
365 await update($, triage, held => (held?.fingerprint === judged.fingerprint ? { ...held, isEdited: true } : held))
366
367 return 'stale'
368 }
369
370 await update($, approved, list => [...new Set([...list, judged.fingerprint])])
371 await update($, triage, held => (held?.fingerprint === judged.fingerprint ? { ...held, isEdited: false } : held))
372
373 return 'approved'
374}
375
376/** Approves, then puts the request to commit in the prompt box, as Send to Claude does for a review. */
377async function approveAndAsk($: EngineInterface, drawn?: string) {
378 const outcome = await approve($, drawn)
379 const judged = await read($, triage)
380
381 if (outcome !== 'approved' || judged === null) {
382 return { outcome, isFilled: false }
383 }
384
385 return { outcome, isFilled: (await fillKeepingDraft($, commitPrompt(judged))).isFilled }
386}
387
388const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
389
390async function gate($: EngineInterface, options: PluginOptions, command: string, isPosix: boolean) {
391 const commit = commitIn(command, isPosix)
392
393 if (commit === undefined) {
394 return undefined
395 }
396
397 const judged = await runTriage($, options, commit.scope, commit.cwd, commit.paths, command)
398
399 if (judged === null || (await read($, approved)).includes(judged.fingerprint)) {
400 return undefined
401 }
402
403 if (judged.verdict === 'hold') {
404 void openPane($)
405
406 return `triage-gate held this commit for human review. Why: ${judged.reasons.join('; ')}. Do not retry the commit or work around this hold. Tell the user: they can read the change, run /gate codex for a Codex review, or run /gate approve to let this exact change through.`
407 }
408
409 if (judged.verdict === 'read') {
410 $.ui.toast(`Committed; review recommended: ${judged.reasons.slice(0, 2).join('; ')}. /gate shows what to read.`, {
411 timeoutMs: 10000,
412 })
413 }
414
415 return undefined
416}
417
418export const register: Register = (on, options) => {
419 on('session.start', async ($, e, next) => {
420 await $.command.register({
421 name: 'gate',
422 description: 'Judge the uncommitted change: blast radius, risk and readability',
423 argumentHint: '[approve | details | codex [what to focus on]]',
424 })
425 await $.command.register({
426 name: 'guide',
427 description: 'Ask for a reading guide to what the last turn changed',
428 })
429
430 return next(e)
431 })
432
433 on('prompt.submit', async ($, e, next) => {
434 if (e.origin.kind === 'composer') {
435 await update($, turn, emptyTally)
436 const text = e.text.trim()
437
438 if (text !== '' && !text.startsWith('/')) {
439 await update($, asks, held => addAsk(held, text))
440 }
441 }
442
443 return next(e)
444 }).catch(($, e, next) => next(e))
445
446 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
447 const ran = await next(e)
448
449 if (ran.deny === undefined && ran.isError !== true) {
450 await update($, turn, held => addWrite(held, e.file_path, e.new_string))
451 await markEdited($)
452 }
453
454 return ran
455 }).catch(($, e, next) => next(e))
456
457 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
458 const ran = await next(e)
459
460 if (ran.deny === undefined && ran.isError !== true) {
461 await update($, turn, held => addWrite(held, e.file_path, e.content))
462 await markEdited($)
463 }
464
465 return ran
466 }).catch(($, e, next) => next(e))
467
468 on('turn.complete', async ($, e, next) => {
469 const done = await next(e)
470
471 if (e.agentId === undefined) {
472 await noteSize($)
473 }
474
475 return done
476 })
477
478 on('command.run', { command: 'guide' }, async $ => {
479 const prompt = guidePrompt((await read($, turn)).files)
480 const filled = await fillKeepingDraft($, prompt)
481
482 return {
483 text: filled.isFilled
484 ? 'The request for a reading guide is in your prompt box. Press Enter to send it.'
485 : `Send this to get a reading guide:\n\n${prompt}`,
486 }
487 })
488
489 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
490 const held = await gate($, options, e.command, true)
491
492 return held === undefined ? next(e) : { deny: held }
493 }).catch(($, e, next) => next(e))
494
495 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
496 const held = await gate($, options, e.command, false)
497
498 return held === undefined ? next(e) : { deny: held }
499 }).catch(($, e, next) => next(e))
500
501 on('command.run', { command: 'gate' }, async ($, e) => {
502 const asked = e.args.trim()
503
504 if (asked === 'approve') {
505 const { outcome, isFilled } = await approveAndAsk($)
506 const judged = await read($, triage)
507
508 return {
509 text:
510 outcome === 'missing'
511 ? 'Nothing has been triaged yet. Run /gate first.'
512 : outcome === 'stale'
513 ? 'Not approved: the change has been edited since it was assessed. Run /gate to assess it again.'
514 : isFilled
515 ? 'Approved. The request to commit is in your prompt box: press Enter to send it. Any further edit needs triage again.'
516 : `Approved. Send this to commit; any further edit needs triage again.\n\n${judged === null ? '' : commitPrompt(judged)}`,
517 }
518 }
519
520 if (asked === 'details') {
521 const judged = await read($, triage)
522
523 return { text: judged === null ? 'Nothing has been triaged yet. Run /gate first.' : detailsText(judged) }
524 }
525
526 if (asked === 'codex' || asked.startsWith('codex ')) {
527 void openPane($)
528 const done = await runCodex($, options, asked.slice('codex'.length).trim())
529
530 return { text: `${reviewHeadline(done)}. See the Triage pane.` }
531 }
532
533 void openPane($)
534 const judged = await runTriage($, options)
535
536 return { text: judged === null ? 'Nothing uncommitted to triage.' : summaryOf(judged) }
537 })
538
539 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
540 const judged = await read($, triage)
541 const view = {
542 judged,
543 reviewed: await read($, review),
544 isApproved: judged !== null && (await read($, approved)).includes(judged.fingerprint),
545 isOpen: await read($, details),
546 isScrollHinted: e.props.placement === 'inline' && !e.props.isFocused,
547 }
548
549 return paneOf($.ui.resolve(e), view, {
550 approve: fingerprint => approveAndAsk($, fingerprint),
551 toggleDetails: () => update($, details, open => !open),
552 triage: () =>
553 runTriage($, options, judged?.subject?.scope, judged?.subject?.cwd, judged?.subject?.paths, judged?.command),
554 codex: () => runCodex($, options),
555 send: text => fillKeepingDraft($, text),
556 })
557 })
558}
559hooks/blast.ts 233 lines1import type { Blast, ChangedFile, Flag } from '../types'
2
3const RISKY_PATH =
4 /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|pom\.xml|build\.gradle(\.kts)?|Cargo\.toml|go\.mod|pyproject\.toml|requirements\.txt|Dockerfile|docker-compose\.ya?ml)$|(^|\/)(migrations?|\.github\/workflows|terraform|infra)\/|\.(sql|tf)$/i
5/** Code where a one-line change can matter more than its size says. */
6export const SENSITIVE_PATH =
7 /auth|login|session|token|secret|credential|password|permission|acl|polic(y|ies)|security|crypto|(^|\/)\.env/i
8const TOO_GENERIC = new Set(['index', 'main', 'mod', 'readme', 'changelog', 'license', 'types', 'utils', 'test'])
9const SHORTEST_STEM = 4
10/** Only code is imported by name; a config or doc file's name is just a common word elsewhere. */
11const CODE_FILE = /\.(c|cc|cpp|cs|go|h|hpp|java|js|jsx|kt|mjs|cjs|mts|cts|php|py|rb|rs|scala|swift|ts|tsx|vue|svelte)$/i
12
13export function parseNumstat(numstat: string): ChangedFile[] {
14 return numstat
15 .split(/\r?\n/)
16 .filter(line => line.trim() !== '')
17 .flatMap(line => {
18 const [added, removed, ...rest] = line.split('\t')
19 const path = rest.join('\t')
20
21 if (path === '' || added === undefined || removed === undefined) {
22 return []
23 }
24
25 return [
26 {
27 path,
28 added: added === '-' ? 0 : Number(added),
29 removed: removed === '-' ? 0 : Number(removed),
30 isNew: false,
31 },
32 ]
33 })
34}
35
36const UNHASHED = /^0+$/
37
38/**
39 * Each path of `git diff --raw -z --no-abbrev --no-renames` with the blob it will commit: `null` when deleted,
40 * `undefined` when git left it unhashed because it lives in the working tree.
41 */
42export function parseRaw(raw: string): { path: string; blob: string | null | undefined }[] {
43 const parts = raw.split('\0')
44 const entries: { path: string; blob: string | null | undefined }[] = []
45
46 for (let at = 0; at + 1 < parts.length; at += 2) {
47 const meta = parts[at] ?? ''
48 const path = parts[at + 1] ?? ''
49
50 if (!meta.startsWith(':')) {
51 break
52 }
53
54 const [, , , blob = '', status = ''] = meta.slice(1).split(' ')
55 entries.push({ path, blob: status.startsWith('D') ? null : UNHASHED.test(blob) ? undefined : blob })
56 }
57
58 return entries
59}
60
61export function stemOf(path: string): string | undefined {
62 const name = path.split('/').pop() ?? path
63 const stem = name.replace(/(\.d)?\.[^.]+$/, '')
64
65 return !CODE_FILE.test(name) || stem.length < SHORTEST_STEM || TOO_GENERIC.has(stem.toLowerCase())
66 ? undefined
67 : stem
68}
69
70const escaped = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71
72/** Where a file is named after the class it holds, the class name is how others reach it. */
73const BY_CLASS_NAME = /\.(cs|java|kt|scala|swift|php)$/i
74
75/**
76 * The `git grep` arguments that find the files using this one: an import, include, require or `mod` naming it, so a
77 * file called `pane.ts` is not "used" by every line that says pane. Languages that name a file after its class match
78 * the name as a word, as before. A Go file is never imported by name, so it gets none.
79 */
80export function usersQuery(path: string): string[] | undefined {
81 const stem = stemOf(path)
82
83 if (stem === undefined || /\.go$/i.test(path)) {
84 return undefined
85 }
86
87 if (BY_CLASS_NAME.test(path)) {
88 return ['-w', '-F', '-e', stem]
89 }
90
91 const name = escaped(stem)
92 const pattern = /\.(c|cc|cpp|h|hpp)$/i.test(path)
93 ? `#include *["<]([^">]*/)?${name}\\.(h|hpp)[">]`
94 : /\.py$/i.test(path)
95 ? `^ *(from|import) ([^ ]*[.])?${name}( |,|[.]|$)|^ *from [^ ]+ import (.*[ ,])?${name}( |,|$)`
96 : /\.rs$/i.test(path)
97 ? `^ *(pub )?(mod|use) ([^;]*::)?${name}( |;|::|,|})`
98 : /\.rb$/i.test(path)
99 ? `require(_relative)?[ (]*['"]([^'"]*/)?${name}(\\.rb)?['"]`
100 : `['"][^'"]*/${name}(\\.[A-Za-z]+)?['"]`
101
102 return ['-E', '-e', pattern]
103}
104
105/** Only a source extension: `./billing.service` names `billing.service.ts`, so its `.service` stays. */
106const withoutExtension = (path: string) => path.replace(/\.(d\.ts|[cm]?[jt]sx?|vue|svelte)$/i, '')
107
108/** `spec` taken from `from`'s folder, with `.` and `..` worked out. */
109function resolved(from: string, spec: string) {
110 const parts: string[] = []
111
112 for (const part of [...folderOf(from).split('/'), ...spec.split('/')]) {
113 if (part === '..') parts.pop()
114 else if (part !== '.' && part !== '') parts.push(part)
115 }
116
117 return parts.join('/')
118}
119
120/** Whether a quoted path on this line names `target`: a relative one must resolve to it, any other is taken on trust. */
121function names(line: string, from: string, target: string) {
122 const specs = [...line.matchAll(/['"]([^'"]+)['"]/g)].map(found => found[1] ?? '')
123
124 return specs.some(spec =>
125 spec.startsWith('.')
126 ? withoutExtension(resolved(from, spec)) === withoutExtension(target)
127 : withoutExtension(spec).endsWith(`/${stemOf(target)}`),
128 )
129}
130
131/**
132 * How many other files use `target`, from `git grep --full-name` lines (`file:text`) for `usersQuery`. A relative
133 * import from a TypeScript or JavaScript file counts only when it resolves to `target`, so a `./pane` beside another
134 * `pane.ts` does not.
135 */
136export function countUsers(grepOutput: string, target: string) {
137 const users = new Set<string>()
138 const isResolved = !BY_CLASS_NAME.test(target) && !/\.(c|cc|cpp|h|hpp|py|rs|rb)$/i.test(target)
139
140 for (const line of grepOutput.split(/\r?\n/)) {
141 const cut = line.indexOf(':')
142 const from = line.slice(0, cut)
143
144 if (cut > 0 && from !== target && (!isResolved || names(line.slice(cut + 1), from, target))) {
145 users.add(from)
146 }
147 }
148
149 return users.size
150}
151
152export const lineCount = (text: string) =>
153 text === '' ? 0 : text.split(/\r?\n/).length - (text.endsWith('\n') ? 1 : 0)
154
155const START_WITH = 5
156const WIDELY_USED = 4
157
158const sizeOf = (file: ChangedFile) => file.added + file.removed
159
160/** Why a file deserves a first look, most telling first; undefined when only its size speaks for it. */
161function concernOf(file: ChangedFile, flags: Flag[]): { rank: number; why: string } | undefined {
162 const pointed = flags.filter(flag => flag.at?.path === file.path).map(flag => flag.label)
163
164 if (SENSITIVE_PATH.test(file.path)) return { rank: 0, why: 'sensitive code: auth, secrets or permissions' }
165 if (pointed.length > 0) return { rank: 1, why: `Jev: ${pointed.join(', ')}` }
166 if (RISKY_PATH.test(file.path)) return { rank: 2, why: 'build, config or schema' }
167 if ((file.usedBy ?? 0) >= WIDELY_USED) return { rank: 3, why: `used by ${file.usedBy} other files` }
168
169 return undefined
170}
171
172const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[^./]+$|(^|\/)test_[^/]+\.py$|Tests?\.(java|kt|cs|swift)$/i
173
174/**
175 * The files to inspect first and why: the ones with a concern, those Jev points at among them, then the biggest. Within
176 * each step code comes before its tests, which make sense once the code is read.
177 */
178export function startWith(blast: Blast, flags: Flag[] = []): { file: ChangedFile; why: string }[] {
179 const isTest = (file: ChangedFile) => (TEST_PATH.test(file.path) ? 1 : 0)
180 const ranked = blast.files
181 .map(file => ({ file, concern: concernOf(file, flags) }))
182 .sort(
183 (a, b) =>
184 (a.concern?.rank ?? 4) - (b.concern?.rank ?? 4) || isTest(a.file) - isTest(b.file) || sizeOf(b.file) - sizeOf(a.file),
185 )
186
187 return ranked.slice(0, START_WITH).map(({ file, concern }) => ({
188 file,
189 why: concern?.why ?? (isTest(file) === 1 ? 'tests for the change' : file.isNew ? 'new file' : 'one of the biggest changes'),
190 }))
191}
192
193const folderOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')
194
195const sharedFolder = (common: string, folder: string): string =>
196 common === '' || folder === common || folder.startsWith(`${common}/`) ? common : sharedFolder(folderOf(common), folder)
197
198/**
199 * The change split into groups a reviewer could take apart: by the first two folders, so `plugins/a` and `plugins/b`
200 * stay apart, each narrowed to the deepest folder all its files share; root files read as `root`.
201 */
202export function groupsOf(files: ChangedFile[]): { name: string; files: number }[] {
203 const byTop = new Map<string, string[]>()
204
205 for (const { path } of files) {
206 const top = folderOf(path).split('/').slice(0, 2).join('/')
207 byTop.set(top, [...(byTop.get(top) ?? []), folderOf(path)])
208 }
209
210 return [...byTop.values()]
211 .map(folders => {
212 const shared = folders.reduce(sharedFolder)
213
214 return { name: shared === '' ? 'root' : shared, files: folders.length }
215 })
216 .sort((a, b) => b.files - a.files)
217}
218
219export function blastOf(files: ChangedFile[]): Blast {
220 const areas = [...new Set(files.map(file => (file.path.includes('/') ? file.path.split('/')[0] ?? '' : '.')))]
221 const widest = [...files]
222 .filter(file => (file.usedBy ?? 0) > 0)
223 .sort((a, b) => (b.usedBy ?? 0) - (a.usedBy ?? 0))[0]
224
225 return {
226 files,
227 lines: files.reduce((sum, file) => sum + file.added + file.removed, 0),
228 areas,
229 risky: files.filter(file => RISKY_PATH.test(file.path)).map(file => file.path),
230 widest,
231 }
232}
233hooks/guide.ts 38 lines1import type { TurnTally } from '../types'
2
3const SIZE_LEVELS = [150, 400, 1000]
4const FILES_NAMED = 12
5
6export const sizeLevel = (lines: number) => SIZE_LEVELS.filter(level => lines >= level).length
7
8export const sizeNote = (lines: number, files: number) =>
9 `Uncommitted change is now ${lines} lines in ${files} ${files === 1 ? 'file' : 'files'}. Commit a slice, or run /gate to see the risk.`
10
11const GUIDE = `Give me a reading guide for the changes you made in your last turn. Do not change any files.
12
131. The behaviour that changed, in one sentence.
142. The entry point and the call path from it, each step as file:line.
153. The two or three files I should read closely, and which of the rest are mechanical.
164. The invariants the new code relies on.
175. What you deliberately left untouched, and why.`
18
19export const emptyTally = (): TurnTally => ({ lines: 0, files: [] })
20
21const lineCount = (text: string) => (text === '' ? 0 : text.split(/\r?\n/).length)
22
23export const addWrite = (held: TurnTally, path: string, text: string): TurnTally => ({
24 lines: held.lines + lineCount(text),
25 files: held.files.includes(path) ? held.files : [...held.files, path],
26})
27
28export function guidePrompt(files: string[]): string {
29 if (files.length === 0) {
30 return GUIDE
31 }
32
33 const named = files.slice(0, FILES_NAMED).join('\n')
34 const more = files.length > FILES_NAMED ? `\nand ${files.length - FILES_NAMED} more` : ''
35
36 return `${GUIDE}\n\nThe files you changed:\n${named}${more}`
37}
38hooks/jev.ts 240 lines1import type { Blast, Jev, Judgment, Place } from '../types'
2import { tagHunks } from './hunks'
3import type { Hunk } from './hunks'
4
5export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
6export const PATCH_CHARS = 60_000
7
8const yesNo = (instructions: string, yes: string, no: string) => ({
9 type: 'noul',
10 instructions,
11 criteria: { true: yes, false: no },
12})
13
14export const QUESTIONS = {
15 behaviour: yesNo(
16 'Does this diff change behaviour that a user or a caller of this code can observe?',
17 'Inputs, outputs, errors, side effects or what a person sees differ after this diff.',
18 'Only refactoring, renaming, formatting, comments or documentation.',
19 ),
20 security: yesNo(
21 'Does this diff change authentication, authorization, secrets, input validation or other security-relevant code?',
22 'Code that decides who may do what, handles credentials, or validates untrusted input changed.',
23 'No security-relevant code changed.',
24 ),
25 data: yesNo(
26 'Does this diff change how data is stored, migrated, deleted or what its schema is?',
27 'Persistence, schemas, migrations or deletion of stored data changed.',
28 'Stored data and its shape are untouched.',
29 ),
30 money: yesNo(
31 'Does this diff change payments, billing, prices or monetary calculations?',
32 'Code that charges, refunds, prices or sums money changed.',
33 'No code that handles money changed.',
34 ),
35 concurrency: yesNo(
36 'Does this diff change concurrency, locking, retries, caching or timing?',
37 'Threads, async ordering, locks, retries, caches or timeouts changed.',
38 'Nothing about ordering, timing or caching changed.',
39 ),
40 publicApi: yesNo(
41 'Does this diff change an interface other code depends on: an exported signature, an HTTP endpoint, a CLI flag or a file format?',
42 'Something other code or other people call or read changed shape.',
43 'Only internal code changed.',
44 ),
45 tested: yesNo(
46 'Does this diff add or change tests that exercise the behaviour it changes?',
47 'Test files in the diff cover the changed behaviour.',
48 'The diff has no tests for what it changes.',
49 ),
50 mixed: yesNo(
51 'Does this diff mix unrelated changes that would be easier to review separately?',
52 'It holds several unrelated changes.',
53 'It is one coherent change.',
54 ),
55 readability: {
56 type: 'score',
57 instructions: 'How hard is this diff for a reviewer to read and understand?',
58 criteria: [
59 'Clear: small steps, good names, intent obvious from the code.',
60 'Mostly clear: a few spots need a second look.',
61 'Effortful: dense logic or unclear names; the intent needs explaining.',
62 'Hard: tangled control flow, several concerns interleaved, intent unclear.',
63 ],
64 },
65} as const
66
67export const LABELS: Record<string, string> = {
68 behaviour: 'changes behaviour',
69 security: 'security',
70 data: 'stored data',
71 money: 'money',
72 concurrency: 'concurrency',
73 publicApi: 'public interface',
74 tested: 'has tests',
75 mixed: 'mixes changes',
76}
77
78/** What each signal measures, in the words the pane shows under it. */
79export const MEANS: Record<string, string> = {
80 behaviour: 'Whether a user or caller would notice a difference: inputs, outputs, errors, side effects.',
81 security: 'Code that decides who may do what, handles credentials, or validates untrusted input.',
82 data: 'How data is stored or migrated, its schema, or deleting it.',
83 money: 'Charging, refunds, prices or sums of money.',
84 concurrency: 'Ordering, locks, retries, caches or timeouts.',
85 publicApi: 'Something other code or people depend on: an exported signature, endpoint, CLI flag or file format.',
86 tested: 'Whether test files in the diff exercise the behaviour it changes.',
87 untested: 'A user or caller would notice a difference, and no test in the diff exercises it.',
88 mixed: 'Several unrelated changes that would be easier to review apart.',
89 readability: 'How much effort a reviewer needs to follow the diff.',
90}
91
92/** The signals worth a "where": each gets a choice among the diff's hunks, asked in the same request. */
93const WHERE_KEYS = ['behaviour', 'security', 'data', 'money', 'concurrency', 'publicApi'] as const
94const NO_HUNK = 'none'
95/** Weight spreads over hunks on a wide diff: on a 45-hunk one, a clear public-interface hunk drew 0.44 for its file. */
96const WHERE_SURE = 0.4
97
98/**
99 * No "no single hunk" option: given one, Jev took it on a wide diff even for signals it rated likely. A nudge
100 * towards code over docs spread the answers further, so it was left out.
101 */
102const whereQuestion = (premise: string, hunks: Hunk[]) => ({
103 type: 'choice',
104 instructions: `Suppose this holds for the diff: ${premise} Each hunk in \`diff\` starts with a tag such as H01|. Which hunk shows it most clearly?`,
105 criteria: { ...Object.fromEntries(hunks.map(hunk => [hunk.id, null])), [NO_HUNK]: 'Nothing in the diff does this.' },
106})
107
108/**
109 * The questions for this change: the fixed ones, plus a "which hunk" for each signal and for readability. They ride
110 * in the same request: only input is billed, and the options are tags, so a "where" adds little. Options keep diff
111 * order, since Jev leans toward the first option. A one-hunk change needs none.
112 */
113export function questionsOf(hunks: Hunk[]) {
114 if (hunks.length < 2) {
115 return QUESTIONS
116 }
117
118 const wheres = WHERE_KEYS.map(key => [`${key}At`, whereQuestion(QUESTIONS[key].criteria.true, hunks)])
119
120 return {
121 ...QUESTIONS,
122 ...Object.fromEntries(wheres),
123 hardestAt: whereQuestion('Some of it is hard for a reviewer to read and understand.', hunks),
124 }
125}
126
127/** The request, and the hunks its tags stand for, to read the answers back with. */
128export function askOf(blast: Blast, patch: string) {
129 const { tagged, hunks } = tagHunks(patch)
130 const body = JSON.stringify({
131 model: 'jev-latest',
132 state: {
133 changed_files: blast.files.map(file => file.path),
134 diff: tagged,
135 },
136 questions: questionsOf(hunks),
137 })
138
139 return { body, hunks }
140}
141
142type RawAnswer = { type?: string; noul?: number; choice?: string; probabilities?: Record<string, number> }
143
144/**
145 * Where a "which hunk" answer points: the file that holds at least `WHERE_SURE` across its hunks and more than "none"
146 * does, and the line of its top hunk when that hunk holds more than half the file's weight. With one hunk there was
147 * no question to ask.
148 */
149function placeOf(answer: RawAnswer | undefined, hunks: Hunk[]): Place | undefined {
150 const [only] = hunks
151
152 if (hunks.length === 1 && only !== undefined) {
153 return { path: only.path, line: only.line }
154 }
155
156 const shares = answer?.probabilities ?? {}
157 const byFile = new Map<string, { weight: number; top?: Hunk; topShare: number }>()
158
159 for (const hunk of hunks) {
160 const share = shares[hunk.id] ?? 0
161 const file = byFile.get(hunk.path) ?? { weight: 0, topShare: 0 }
162 byFile.set(hunk.path, {
163 weight: file.weight + share,
164 ...(share > file.topShare ? { top: hunk, topShare: share } : { top: file.top, topShare: file.topShare }),
165 })
166 }
167
168 const [path, best] = [...byFile].sort(([, a], [, b]) => b.weight - a.weight)[0] ?? []
169
170 if (path === undefined || best === undefined || best.weight < WHERE_SURE || best.weight <= (shares[NO_HUNK] ?? 0)) {
171 return undefined
172 }
173
174 return best.top !== undefined && best.topShare > best.weight / 2 ? { path, line: best.top.line } : { path }
175}
176
177/** A place as a reviewer types it: `path:line`, or the path alone. */
178export const placeText = (at: Place) => (at.line === undefined ? at.path : `${at.path}:${at.line}`)
179
180const tokensOf = (value: unknown) => {
181 const usage = typeof value === 'object' && value !== null && 'usage' in value ? value.usage : undefined
182 const input = typeof usage === 'object' && usage !== null && 'input_tokens' in usage ? usage.input_tokens : undefined
183
184 return typeof input === 'number' ? input : undefined
185}
186
187const asAnswers = (value: unknown): Record<string, RawAnswer> | undefined =>
188 typeof value === 'object' && value !== null && 'answers' in value
189 ? (value.answers as Record<string, RawAnswer>)
190 : undefined
191
192export function parseJev(status: number, text: string, hunks: Hunk[] = []): Exclude<Jev, { status: 'off' }> {
193 if (status === 401) {
194 return { status: 'failed', note: 'Jev refused the API key (401).' }
195 }
196
197 if (status === 429 || status === 529) {
198 return { status: 'failed', note: `Jev is busy (${status}); triage used blast radius only.` }
199 }
200
201 if (status < 200 || status >= 300) {
202 return { status: 'failed', note: `Jev answered ${status}.` }
203 }
204
205 let answers: Record<string, RawAnswer> | undefined
206 let tokens: number | undefined
207
208 try {
209 const parsed: unknown = JSON.parse(text)
210 answers = asAnswers(parsed)
211 tokens = tokensOf(parsed)
212 } catch {
213 answers = undefined
214 }
215
216 if (answers === undefined) {
217 return { status: 'failed', note: 'Jev sent an answer triage could not read.' }
218 }
219
220 const judgments: Judgment[] = Object.keys(LABELS).flatMap(key => {
221 const value = answers?.[key]?.noul
222 const at = placeOf(answers?.[`${key}At`], hunks)
223
224 return typeof value === 'number' ? [{ key, label: LABELS[key] ?? key, value, ...(at === undefined ? {} : { at }) }] : []
225 })
226 const levels = Object.entries(answers.readability?.probabilities ?? {}).sort(
227 ([a], [b]) => Number(a) - Number(b),
228 )
229 const hardToRead = levels.slice(-2).reduce((sum, [, share]) => sum + share, 0)
230 const hardestAt = placeOf(answers.hardestAt, hunks)
231
232 return {
233 status: 'judged',
234 judgments,
235 hardToRead,
236 ...(hardestAt === undefined ? {} : { hardestAt }),
237 ...(tokens === undefined ? {} : { tokens }),
238 }
239}
240hooks/pane.tsx 314 lines1import type { Elements, RenderInput } from 'claude-code'
2
3import type { Flag, Review, Triage } from '../types'
4import { startWith } from './blast'
5import { MEANS, placeText } from './jev'
6import { BADGE, changeLine, coverageOf, diagnosticsOf, reviewHeadline, subjectKey } from './report'
7import { fixPrompt, tookOf } from './review'
8import { clearOf, flagsOf, groupsLine, testsNoteOf } from './verdict'
9
10export type PaneView = {
11 judged: Triage | null
12 reviewed: Review
13 isApproved: boolean
14 isOpen: boolean
15 isScrollHinted: boolean
16}
17
18export type PaneActions = {
19 approve: (fingerprint: string) => Promise<unknown>
20 toggleDetails: () => Promise<unknown>
21 triage: () => Promise<unknown>
22 codex: () => Promise<unknown>
23 send: (text: string) => Promise<unknown>
24}
25
26export function paneOf(ui: Elements[RenderInput<'Pane'>['surface']], view: PaneView, act: PaneActions) {
27 const { Box, Button, Text } = ui
28 const { judged, reviewed, isApproved, isOpen } = view
29
30 const isOtherChange =
31 reviewed.status === 'done' &&
32 reviewed.subject !== undefined &&
33 judged?.subject !== undefined &&
34 subjectKey(judged.subject) !== reviewed.subject
35 const isStale =
36 !isOtherChange &&
37 reviewed.status === 'done' &&
38 (reviewed.isEdited === true ||
39 (judged !== null && reviewed.fingerprint !== undefined && reviewed.fingerprint !== judged.fingerprint))
40 const reviewBox = (
41 <Box flexDirection="column">
42 {reviewed.status === 'running' && (
43 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="suggestion" paddingX={1}>
44 <Box>
45 <Text bold color="suggestion">
46 Codex is reviewing…
47 </Text>
48 <Box flexGrow={1} />
49 <Text dimColor>{tookOf(reviewed.tookMs)}</Text>
50 </Box>
51 <Text dimColor wrap="truncate-end">
52 {reviewed.doing ?? 'Starting Codex. A review usually takes a few minutes.'}
53 </Text>
54 <Text dimColor wrap="wrap">
55 Reviewing {reviewed.covers}. This run is billed to your Codex account.
56 </Text>
57 </Box>
58 )}
59
60 {isOtherChange && (
61 <Box marginTop={1}>
62 <Text dimColor wrap="wrap">
63 An earlier Codex review covered a different change. Run Codex review to check this one.
64 </Text>
65 </Box>
66 )}
67
68 {reviewed.status === 'done' && !isOtherChange && (
69 <Box
70 flexDirection="column"
71 marginTop={1}
72 borderStyle="round"
73 borderColor={reviewed.findings.length > 0 || !reviewed.isClean ? 'warning' : 'subtle'}
74 paddingX={1}
75 >
76 <Box>
77 <Text bold color={reviewed.findings.length > 0 || !reviewed.isClean ? 'warning' : 'success'}>
78 {reviewHeadline(reviewed)}
79 </Text>
80 <Box flexGrow={1} />
81 <Text dimColor>{tookOf(reviewed.tookMs)}</Text>
82 </Box>
83 <Text dimColor wrap="wrap">
84 Covered {reviewed.covers}.
85 </Text>
86 {reviewed.brief !== undefined && (
87 <Box flexDirection="column">
88 <Text dimColor>Codex was told this is what was built:</Text>
89 <Text dimColor wrap="wrap">
90 {reviewed.brief}
91 </Text>
92 </Box>
93 )}
94 {isStale && (
95 <Text color="warning" wrap="wrap">
96 ▲ The change has been edited since this review. Run it again.
97 </Text>
98 )}
99 {reviewed.summary !== '' && <Text wrap="wrap">{reviewed.summary}</Text>}
100 {reviewed.findings.map(one => (
101 <Box flexDirection="column" marginTop={1}>
102 <Box>
103 <Text bold color={one.priority === 'P1' ? 'error' : 'warning'}>
104 {one.priority}{' '}
105 </Text>
106 <Box flexShrink={1}>
107 <Text bold wrap="wrap">
108 {one.title}
109 </Text>
110 </Box>
111 </Box>
112 <Text color="suggestion" wrap="truncate-end">
113 ↳ {one.at}
114 </Text>
115 <Text dimColor wrap="wrap">
116 {one.body}
117 </Text>
118 </Box>
119 ))}
120 {reviewed.findings.length > 0 && (
121 <Box marginTop={1}>
122 <Button
123 key="fix"
124 label="Send to Claude"
125 variant="primary"
126 onPress={async () => {
127 await act.send(fixPrompt(reviewed.findings))
128 }}
129 />
130 </Box>
131 )}
132 </Box>
133 )}
134 </Box>
135 )
136
137 if (judged === null) {
138 return (
139 <Box flexDirection="column">
140 <Text dimColor wrap="wrap">
141 Nothing triaged yet. Triage runs when Claude commits, or type /gate.
142 </Text>
143 {reviewBox}
144 </Box>
145 )
146 }
147
148 const badge = BADGE[judged.verdict]
149 const isOutdated = judged.isEdited === true
150 const { jev } = judged
151 const isWhole = jev.status === 'judged' && !judged.isPatchCut
152 const heading = (text: string) => (
153 <Box marginTop={1}>
154 <Text bold>{text}</Text>
155 </Box>
156 )
157 const flags = flagsOf(judged.blast, jev)
158 const clear = clearOf(jev)
159 const testsNote = testsNoteOf(jev)
160 const flagged = (flag: Flag) => (
161 <Box key={flag.key} flexDirection="column" marginTop={1}>
162 <Box>
163 <Box flexGrow={1} flexShrink={1}>
164 <Text wrap="truncate-end">{flag.label}</Text>
165 </Box>
166 <Text color={flag.isLikely ? 'error' : 'warning'}>{flag.isLikely ? 'likely' : 'possible'}</Text>
167 </Box>
168 <Text dimColor wrap="wrap">
169 {MEANS[flag.key] ?? ''}
170 </Text>
171 {flag.at !== undefined && (
172 <Text color="suggestion" wrap="truncate-start">
173 ↳ {placeText(flag.at)}
174 </Text>
175 )}
176 {flag.groups !== undefined && (
177 <Text color="suggestion" wrap="wrap">
178 ↳ {groupsLine(flag.groups)}
179 </Text>
180 )}
181 </Box>
182 )
183
184 return (
185 <Box flexDirection="column">
186 <Box>
187 <Text color={badge.tone} bold>
188 {badge.mark} {badge.word}
189 </Text>
190 <Box flexGrow={1} />
191 {isApproved && <Text color="success">approved</Text>}
192 </Box>
193 <Text dimColor wrap="wrap">
194 {badge.means}
195 </Text>
196 <Text wrap="wrap">{changeLine(judged)}</Text>
197 <Text color={isWhole ? undefined : 'warning'} dimColor={isWhole} wrap="wrap">
198 {coverageOf(judged)}
199 </Text>
200 {isOutdated && (
201 <Text color="warning" wrap="wrap">
202 ▲ The change has been edited since this assessment. Refresh it.
203 </Text>
204 )}
205 {judged.verdict === 'hold' && isApproved && !isOutdated && (
206 <Text color="success" wrap="wrap">
207 Approved. The request to commit is in your prompt box: press Enter to send it.
208 </Text>
209 )}
210
211 <Box marginTop={1} gap={1} flexWrap="wrap">
212 {judged.verdict === 'hold' && !isApproved && !isOutdated && (
213 <Button
214 key="approve"
215 label="Approve commit"
216 variant="primary"
217 onPress={() => act.approve(judged.fingerprint)}
218 />
219 )}
220 <Button
221 key="codex"
222 label={reviewed.status === 'running' ? 'Codex reviewing…' : 'Run Codex review'}
223 onPress={async () => {
224 if (reviewed.status !== 'running') {
225 await act.codex()
226 }
227 }}
228 />
229 <Button
230 key="rerun"
231 label="Refresh assessment"
232 {...(isOutdated ? { variant: 'primary' as const } : {})}
233 onPress={async () => void (await act.triage())}
234 />
235 </Box>
236 {view.isScrollHinted && (
237 <Text dimColor wrap="wrap">
238 Click the pane or press ctrl+x tab, then use the arrow keys to scroll to Why, Start with and the signals.
239 </Text>
240 )}
241
242 {judged.reasons.length > 0 && heading('Why')}
243 {judged.reasons.map(reason => (
244 <Text wrap="wrap">· {reason}</Text>
245 ))}
246
247 {heading('Start with')}
248 {startWith(judged.blast, flags).map(({ file, why }) => (
249 <Box key={file.path}>
250 <Box flexShrink={1}>
251 <Text color="suggestion" wrap="truncate-start">
252 {file.path}
253 </Text>
254 </Box>
255 <Box flexGrow={1} flexShrink={2}>
256 <Text dimColor wrap="truncate-end">
257 {' '}· {why}
258 </Text>
259 </Box>
260 </Box>
261 ))}
262
263 {reviewBox}
264
265 <Box marginTop={1}>
266 <Button
267 key="details"
268 plain
269 dimColor
270 label={isOpen ? '▾ Hide detailed signals' : '▸ Detailed signals'}
271 onPress={() => act.toggleDetails()}
272 />
273 </Box>
274 {isOpen && jev.status !== 'judged' && (
275 <Text dimColor wrap="wrap">
276 {jev.status === 'off'
277 ? 'Jev is not configured: set the TypeSafe API key in /config or TYPESAFE_API_KEY.'
278 : 'Jev gave no judgement this time; see the line under the change summary.'}
279 </Text>
280 )}
281 {isOpen && jev.status === 'judged' && (
282 <Box flexDirection="column">
283 <Text dimColor wrap="wrap">
284 {flags.length === 0 ? 'Jev flagged nothing in this diff.' : 'What Jev flagged in this diff, and where to look.'}
285 </Text>
286 {flags.map(flagged)}
287 {(clear.length > 0 || testsNote !== undefined) && (
288 <Box flexDirection="column" marginTop={1}>
289 {clear.length > 0 && (
290 <Text dimColor wrap="wrap">
291 Not flagged: {clear.join(', ')}.
292 </Text>
293 )}
294 {testsNote !== undefined && (
295 <Text dimColor wrap="wrap">
296 {testsNote}
297 </Text>
298 )}
299 </Box>
300 )}
301 </Box>
302 )}
303 {isOpen && (
304 <Box flexDirection="column">
305 {heading('Diagnostics')}
306 <Text dimColor wrap="wrap">
307 {diagnosticsOf(judged)}
308 </Text>
309 </Box>
310 )}
311 </Box>
312 )
313}
314hooks/report.ts 139 lines1import type { Place, Review, Subject, Triage, Verdict } from '../types'
2import { startWith } from './blast'
3import { PATCH_CHARS, placeText } from './jev'
4import { flagsOf, likelihood, percent } from './verdict'
5
6/** One string per judged change, so a Codex review can tell whether a later triage is of the same change. */
7export const subjectKey = (subject: Subject | undefined) =>
8 subject === undefined ? undefined : [subject.scope, subject.cwd ?? '', ...(subject.paths ?? [])].join('\n')
9
10const SHOWN_REASONS = 3
11
12/** A pass only means no trigger fired, not that the change is safe, so it is not drawn in green. */
13export const BADGE: Record<Verdict, { mark: string; word: string; means: string; tone: 'suggestion' | 'warning' | 'error' }> = {
14 pass: {
15 mark: '○',
16 word: 'No review trigger',
17 means: 'Nothing here called for a review. This is not a safety check.',
18 tone: 'suggestion',
19 },
20 read: {
21 mark: '◐',
22 word: 'Review recommended',
23 means: "Claude's commits go through; read the files below before you build on this.",
24 tone: 'warning',
25 },
26 hold: {
27 mark: '■',
28 word: 'Hold: review before commit',
29 means: "Claude's commit is blocked until you approve this exact change.",
30 tone: 'error',
31 },
32}
33
34/** What this verdict rests on, so a pass from local checks alone is not read as a full one. */
35export function coverageOf(judged: Triage) {
36 const { jev } = judged
37
38 if (jev.status === 'off') {
39 return 'Assessed with local checks only: size, spread and file paths. Add a TypeSafe key for Jev.'
40 }
41 if (jev.status === 'failed') return `Assessed with local checks only: Jev gave no judgement. ${jev.note}`
42 if (judged.isPatchCut) return `Assessed on part of the diff: Jev read only the first ${PATCH_CHARS / 1000}k characters.`
43
44 return 'Assessed on the whole diff: local checks and Jev.'
45}
46
47export function reviewHeadline(done: Extract<Review, { status: 'done' }>) {
48 if (done.findings.length > 0) return `Codex found ${done.findings.length} ${done.findings.length === 1 ? 'problem' : 'problems'}`
49 if (done.isTimedOut === true) return 'Codex review timed out; it listed no findings'
50 if (!done.isClean) return 'Codex review failed; it listed no findings'
51
52 return 'Codex review finished with no findings listed'
53}
54
55/** What approving puts in the prompt box: Claude's own commit command again, or a plain request when `/gate` judged it. */
56export function commitPrompt(judged: Triage) {
57 return judged.command === undefined
58 ? 'I reviewed and approved the uncommitted change in the Triage pane. Commit it.'
59 : `I reviewed and approved this change in the Triage pane. Run the commit again, unchanged:\n\n\`\`\`\n${judged.command}\n\`\`\``
60}
61
62/** The `/gate` reply: the verdict, the change, the top reasons, and where the rest is. */
63export function summaryOf(judged: Triage) {
64 const { reasons } = judged
65 const more = reasons.length - SHOWN_REASONS
66
67 return [
68 `${BADGE[judged.verdict].word}. ${changeLine(judged)}.`,
69 ...reasons.slice(0, SHOWN_REASONS).map(reason => `- ${reason}`),
70 ...(more > 0 ? [`- and ${more} more`] : []),
71 'The Triage pane lists the files to read first; /gate details prints everything.',
72 ].join('\n')
73}
74
75const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
76
77export function changeLine(judged: Triage) {
78 const { files, areas } = judged.blast
79 const added = files.reduce((sum, file) => sum + file.added, 0)
80 const removed = files.reduce((sum, file) => sum + file.removed, 0)
81
82 const named = areas.map(area => (area === '.' ? 'root' : area)).join(', ')
83
84 return `${plural(files.length, 'file')} · +${added} −${removed} lines · ${plural(areas.length, 'area')}: ${named}`
85}
86
87const agoOf = (at: number | undefined) => {
88 if (at === undefined) return undefined
89
90 const minutes = Math.floor((Date.now() - at) / 60_000)
91
92 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h ago`
93}
94
95/** Who judged, how fast, on what and when: never the key. */
96export function diagnosticsOf(judged: Triage) {
97 const { jev } = judged
98 const took = jev.status !== 'off' && jev.tookMs !== undefined ? ` in ${(jev.tookMs / 1000).toFixed(1)}s` : ''
99 const read = jev.status === 'judged' && jev.tokens !== undefined ? `, read ${(jev.tokens / 1000).toFixed(1)}k tokens` : ''
100 const provider =
101 jev.status === 'judged'
102 ? `Jev (TypeSafe) answered${took}${read}`
103 : jev.status === 'failed'
104 ? `Jev (TypeSafe) failed${took}; fell back to local checks`
105 : 'Jev off; local checks only'
106 const ago = agoOf(judged.assessedAt)
107
108 return [provider, ago === undefined ? undefined : `assessed ${ago}`, judged.isEdited === true ? 'stale' : undefined]
109 .filter(Boolean)
110 .join(' · ')
111}
112
113const inFile = (at: Place | undefined) => (at === undefined ? '' : `, mostly in ${placeText(at)}`)
114
115/** The pane's detail, as text: for `/gate details` where the pane cannot be shown. */
116export function detailsText(judged: Triage) {
117 const { jev } = judged
118 const signals =
119 jev.status === 'judged'
120 ? [
121 ...jev.judgments.map(one => `${one.label}: ${likelihood(one.value)} (${percent(one.value)})${inFile(one.at)}`),
122 `hard to follow: ${likelihood(jev.hardToRead)} (${percent(jev.hardToRead)})${inFile(jev.hardestAt)}`,
123 ]
124 : []
125 const files = startWith(judged.blast, flagsOf(judged.blast, jev)).map(({ file, why }) => `${file.path} (+${file.added} −${file.removed}): ${why}`)
126
127 return [
128 `${BADGE[judged.verdict].word}. ${changeLine(judged)}.`,
129 coverageOf(judged),
130 ...(judged.reasons.length > 0 ? ['', 'Why:', ...judged.reasons.map(reason => `- ${reason}`)] : []),
131 '',
132 'Start with:',
133 ...files.map(line => `- ${line}`),
134 ...(signals.length > 0 ? ['', "Jev's signals:", ...signals.map(line => `- ${line}`)] : []),
135 '',
136 diagnosticsOf(judged),
137 ].join('\n')
138}
139hooks/review.ts 111 lines1import type { Finding, Triage } from '../types'
2import { SENSITIVE_PATH } from './blast'
3import { flagsOf } from './verdict'
4
5const FILES_REVIEWED = 15
6const FINDINGS_ASKED = 5
7const ASKS_KEPT = 3
8const ASK_CHARS = 600
9export const BRIEF_CHARS = 900
10
11export const BRIEF_PROMPT = `A reviewer who has not seen this conversation will check the uncommitted changes against what was wanted. In at most six short lines, with no preamble, say: the requirement those changes implement, the behaviour that should now hold, and what was deliberately left out of scope.`
12
13export const addAsk = (asks: string[], text: string) => [...asks, text.slice(0, ASK_CHARS)].slice(-ASKS_KEPT)
14
15export type Intent = { asks: string[]; brief?: string }
16
17export function reviewScope(judged: Triage | null, focus: string) {
18 const blast = judged?.blast
19 const pointed = judged === null ? [] : flagsOf(judged.blast, judged.jev).map(flag => flag.at?.path)
20 const isFlagged = (path: string) =>
21 SENSITIVE_PATH.test(path) ||
22 (blast?.risky.includes(path) ?? false) ||
23 blast?.widest?.path === path ||
24 pointed.includes(path)
25 const bySize = [...(blast?.files ?? [])].sort((a, b) => b.added + b.removed - (a.added + a.removed))
26 const flagged = bySize.filter(file => isFlagged(file.path))
27 const files = [...flagged, ...bySize.filter(file => !isFlagged(file.path))]
28 .slice(0, FILES_REVIEWED)
29 .map(file => file.path)
30 const changed = blast?.files.length ?? 0
31 const covers =
32 files.length === 0
33 ? 'every uncommitted change'
34 : files.length === changed
35 ? `the ${changed} changed ${changed === 1 ? 'file' : 'files'}`
36 : flagged.length === 0
37 ? `the ${files.length} largest of ${changed} changed files`
38 : `${files.length} of ${changed} changed files, flagged ones first, then the largest`
39
40 return { files, label: focus === '' ? covers : `${covers}, focus: ${focus}` }
41}
42
43export function reviewPrompt(judged: Triage | null, focus: string, intent: Intent = { asks: [] }): string {
44 const { files } = reviewScope(judged, focus)
45
46 return [
47 'Review the uncommitted changes in this repository. Do not change any files.',
48 files.length > 0 ? `Review only these files, and read others only when one of these calls them:\n${files.join('\n')}` : '',
49 intent.asks.length > 0
50 ? `What the user asked for, in their own words, oldest first:\n${intent.asks.map(ask => `> ${ask.replaceAll('\n', ' ')}`).join('\n')}`
51 : '',
52 intent.brief === undefined
53 ? ''
54 : `The implementer's own summary of what was built. Check the code against it; it may be wrong:\n${intent.brief}`,
55 intent.asks.length > 0 || intent.brief !== undefined
56 ? 'Report where the code does not do what was asked, as well as plain bugs.'
57 : '',
58 judged !== null && judged.reasons.length > 0 ? `An earlier triage flagged: ${judged.reasons.join('; ')}.` : '',
59 focus === '' ? '' : `Focus on: ${focus}`,
60 `Report at most ${FINDINGS_ASKED} findings, the most serious first. Write one or two sentences of summary, then a line "Review comments:", then each finding in exactly this shape:`,
61 '- [P1] <short title> -- <file>:<line>\n <one or two sentences on what goes wrong and when>',
62 'Use P1 for a bug that will bite and P2 for the rest. If nothing is wrong, say so and list no findings.',
63 ]
64 .filter(Boolean)
65 .join('\n\n')
66}
67
68const FINDING = /^- \[(P\d)\] (.+?) (?:—|--) (\S+?:\d+(?:-\d+)?)\s*$/
69const COMMENTS_HEADING = /^(full )?review comments?:\s*$/i
70
71export function parseReview(text: string): { summary: string; findings: Finding[] } {
72 const findings: Finding[] = []
73 const summary: string[] = []
74
75 for (const line of text.split(/\r?\n/)) {
76 const found = FINDING.exec(line)
77 const last = findings[findings.length - 1]
78
79 if (found !== null) {
80 findings.push({ priority: found[1] ?? '', title: found[2] ?? '', at: found[3] ?? '', body: '' })
81 } else if (last !== undefined) {
82 last.body = `${last.body} ${line.trim()}`.trim()
83 } else if (!COMMENTS_HEADING.test(line.trim())) {
84 summary.push(line)
85 }
86 }
87
88 return { summary: summary.join('\n').trim(), findings }
89}
90
91export const lastLine = (text: string) =>
92 text
93 .split(/\r?\n/)
94 .map(line => line.trim())
95 .filter(Boolean)
96 .pop()
97
98export const tookOf = (ms: number) => {
99 const seconds = Math.round(ms / 1000)
100
101 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
102}
103
104export function fixPrompt(findings: Finding[]): string {
105 const listed = findings
106 .map(one => `- [${one.priority}] ${one.title} (${one.at})\n ${one.body}`)
107 .join('\n')
108
109 return `Codex reviewed the uncommitted change and reported the findings below. Check each one against the code. Fix the ones that are real, and tell me which you rejected and why.\n\n${listed}`
110}
111hooks/shell.ts 229 lines1// The shell splitting is adapted from prismantis (MIT), hooks/render.tsx:
2// https://github.com/NahumLitvin/prismantis
3import type { Scope } from '../types'
4
5/** With `paths`, the commit takes what was staged before plus what `git add` stages from those paths. */
6export type CommitCall = { cwd?: string; scope: Scope; paths?: string[] }
7
8const HEREDOC = /<<(?!<)-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g
9const SHELLS = new Set(['bash', 'sh', 'zsh', 'pwsh', 'powershell'])
10const MOVES = new Set(['cd', 'pushd', 'set-location'])
11const LEADING_KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', 'time'])
12const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env'])
13
14function dropHeredocBodies(command: string): string {
15 const lines = command.split('\n')
16 const kept: string[] = []
17
18 for (let at = 0; at < lines.length; at += 1) {
19 const line = lines[at] ?? ''
20 kept.push(line)
21
22 for (const [, , end] of line.matchAll(HEREDOC)) {
23 while (at + 1 < lines.length && (lines[at + 1] ?? '').trim() !== end) {
24 at += 1
25 }
26
27 at += 1
28 }
29 }
30
31 return kept.join('\n')
32}
33
34export function segmentsOf(source: string, isPosix: boolean): string[][] {
35 const command = dropHeredocBodies(source)
36 const segments: string[][] = [[]]
37 let word = ''
38 let quote = ''
39 let isStarted = false
40
41 const endWord = () => {
42 if (isStarted) {
43 segments[segments.length - 1]?.push(word)
44 }
45
46 word = ''
47 isStarted = false
48 }
49 const endSegment = () => {
50 endWord()
51 segments.push([])
52 }
53
54 for (let at = 0; at < command.length; at += 1) {
55 const char = command[at] ?? ''
56 const next = command[at + 1] ?? ''
57
58 if (quote !== '') {
59 if (char === quote) {
60 quote = ''
61 } else if (isPosix && char === '\\' && quote === '"' && /[$`"\\]/.test(next)) {
62 word += next
63 at += 1
64 } else {
65 word += char
66 }
67 } else if (char === '"' || char === "'") {
68 quote = char
69 isStarted = true
70 } else if (isPosix && char === '\\' && next === '\n') {
71 at += 1
72 } else if (isPosix && char === '\\' && next !== '') {
73 word += next
74 isStarted = true
75 at += 1
76 } else if (char === '\n' || char === ';' || char === '|' || char === '&' || char === '(' || char === ')') {
77 endSegment()
78 } else if (/\s/.test(char)) {
79 endWord()
80 } else {
81 word += char
82 isStarted = true
83 }
84 }
85
86 endWord()
87
88 return segments.filter(words => words.length > 0)
89}
90
91const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
92
93function commandWords(words: string[]): string[] {
94 const from = words.findIndex(word => !isAssignment(word) && !LEADING_KEYWORDS.has(word))
95
96 return from < 0 ? [] : words.slice(from)
97}
98
99const programOf = (word: string | undefined) =>
100 (word ?? '')
101 .replaceAll('\\', '/')
102 .split('/')
103 .pop()
104 ?.replace(/\.exe$/i, '')
105 .toLowerCase() ?? ''
106
107const isAbsolute = (path: string) => /^([A-Za-z]:)?[\\/]/.test(path) || path.startsWith('~')
108
109const under = (base: string | undefined, path: string | undefined) => {
110 if (path === undefined) return base
111 if (base === undefined || isAbsolute(path)) return path
112
113 return `${base}/${path}`
114}
115
116function gitCall(args: string[]) {
117 let dir: string | undefined
118 let at = 0
119
120 while (at < args.length && (args[at] ?? '').startsWith('-')) {
121 const option = args[at] ?? ''
122
123 if (option === '-C') {
124 dir = under(dir, args[at + 1])
125 }
126
127 at += GIT_OPTIONS_WITH_VALUE.has(option) ? 2 : 1
128 }
129
130 return { dir, verb: args[at], rest: args.slice(at + 1) }
131}
132
133const COMMIT_OPTIONS_WITH_VALUE = /^(-[a-zA-Z]*[mFCct]|--(message|file|author|date|fixup|squash|template|cleanup|reuse-message|reedit-message|trailer))$/
134
135const commitsAll = (word: string) => word === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(word)
136
137/** A redirection such as `<<EOF`, `2>/dev/null` or `>`; a bare operator takes the next word as its target. */
138const REDIRECT = /^\d*(<<?-?|>>?)/
139const isBareRedirect = (word: string) => /^\d*(<<?-?|>>?)$/.test(word)
140
141function commitsWorkingTree(args: string[]): boolean {
142 for (let at = 0; at < args.length; at += 1) {
143 const word = args[at] ?? ''
144
145 if (REDIRECT.test(word)) {
146 if (isBareRedirect(word)) at += 1
147 continue
148 }
149
150 if (commitsAll(word) || word === '--' || /^(-[a-zA-Z]*[oi][a-zA-Z]*|--only|--include)$/.test(word)) return true
151 if (COMMIT_OPTIONS_WITH_VALUE.test(word)) at += 1
152 else if (!word.startsWith('-')) return true
153 }
154
155 return false
156}
157
158type Added = { scope: Scope; paths: string[] }
159
160/** What `git add` stages. Anything it cannot place exactly counts as the whole tree. */
161function addedBy(args: string[]): Added {
162 const paths: string[] = []
163 let isUpdate = false
164 let isAll = false
165
166 for (let at = 0; at < args.length; at += 1) {
167 const word = args[at] ?? ''
168
169 if (word === '--') {
170 paths.push(...args.slice(at + 1))
171 break
172 }
173
174 if (!word.startsWith('-')) paths.push(word)
175 else if (word === '-u' || word === '--update') isUpdate = true
176 else if (word === '-A' || word === '--all') isAll = true
177 else if (/^(-[a-zA-Z]*[pie][a-zA-Z]*|--(patch|interactive|edit)|--pathspec-from-file.*)$/.test(word))
178 return { scope: 'tree', paths: [] }
179 }
180
181 if (paths.length > 0) return { scope: 'tree', paths }
182
183 return { scope: isUpdate && !isAll ? 'tracked' : 'tree', paths: [] }
184}
185
186/** The union of the adds before a commit; one that reaches the whole tree, or runs from another folder, wins. */
187function joinAdds(adds: (Added & { dir?: string })[], commitDir: string | undefined): Pick<CommitCall, 'scope' | 'paths'> {
188 if (adds.length === 0) return { scope: 'tree' }
189 if (adds.some(add => add.paths.length === 0 || add.dir !== commitDir)) {
190 return { scope: adds.every(add => add.scope === 'tracked' && add.dir === commitDir) ? 'tracked' : 'tree' }
191 }
192
193 return { scope: 'tree', paths: adds.flatMap(add => add.paths) }
194}
195
196export function commitIn(command: string, isPosix = true, cwd?: string): CommitCall | undefined {
197 let dir = cwd
198 const adds: (Added & { dir?: string })[] = []
199
200 for (const words of segmentsOf(command, isPosix)) {
201 const [name, ...rest] = commandWords(words)
202 const program = programOf(name)
203
204 if (MOVES.has(program)) {
205 dir = under(dir, rest.find(word => !word.startsWith('-')))
206 } else if (SHELLS.has(program)) {
207 const script = rest[rest.findIndex(word => /^-(c|command)$/i.test(word)) + 1]
208 const inner = script === undefined ? undefined : commitIn(script, isPosix, dir)
209
210 if (inner !== undefined) return inner
211 } else if (program === 'git') {
212 const call = gitCall(rest)
213
214 if (call.verb === 'add') adds.push({ ...addedBy(call.rest), dir: under(dir, call.dir) })
215
216 if (call.verb === 'commit') {
217 const commitDir = under(dir, call.dir)
218
219 if (adds.length === 0) return { cwd: commitDir, scope: commitsWorkingTree(call.rest) ? 'tracked' : 'staged' }
220 if (commitsWorkingTree(call.rest)) return { cwd: commitDir, scope: 'tree' }
221
222 return { cwd: commitDir, ...joinAdds(adds, commitDir) }
223 }
224 }
225 }
226
227 return undefined
228}
229hooks/verdict.ts 160 lines1import type { Blast, Flag, Jev, Place, Verdict } from '../types'
2import { SENSITIVE_PATH, groupsOf } from './blast'
3import { placeText } from './jev'
4
5const MANY_LINES = 400
6const SOME_LINES = 150
7const MANY_FILES = 10
8const SOME_FILES = 5
9const MANY_AREAS = 4
10const WIDE_USE = 10
11const SOME_USE = 4
12const LIKELY = 0.7
13const UNSURE = 0.4
14const UNLIKELY = 0.3
15const HARD = 0.5
16const EFFORTFUL = 0.25
17const HOLD_AT = 5
18const READ_AT = 2
19
20const SENSITIVE = ['security', 'data', 'money', 'concurrency', 'publicApi']
21
22/** Each kind of signal counts up to a cap, so a hold needs more than one kind to agree. */
23const CAPS = { size: 3, reach: 2, risk: 2, quality: 2 }
24
25export const percent = (value: number) => `${Math.round(value * 100)}%`
26
27/** How likely, in a word: `/gate details` leads with it and gives the number after. */
28export const likelihood = (value: number) => (value >= LIKELY ? 'likely' : value >= UNLIKELY ? 'possible' : 'unlikely')
29
30/**
31 * The Jev signals that fired, each with the file to look at. A change in behaviour alone is not one: nearly every diff
32 * has it. It fires only when no tests in the diff cover it.
33 */
34export function flagsOf(blast: Blast, jev: Jev): Flag[] {
35 if (jev.status !== 'judged') {
36 return []
37 }
38
39 const find = (key: string) => jev.judgments.find(one => one.key === key)
40 const where = (at: Place | undefined) => (at === undefined ? {} : { at })
41 const flags: Flag[] = jev.judgments
42 .filter(one => SENSITIVE.includes(one.key) && one.value >= UNSURE)
43 .map(one => ({
44 key: one.key,
45 label: one.label,
46 said: one.value >= LIKELY ? `likely touches ${one.label}` : `may touch ${one.label}`,
47 isLikely: one.value >= LIKELY,
48 ...where(one.at),
49 }))
50 const behaviour = find('behaviour')
51
52 if ((behaviour?.value ?? 0) >= LIKELY && (find('tested')?.value ?? 1) < UNLIKELY) {
53 flags.push({
54 key: 'untested',
55 label: 'untested behaviour change',
56 said: 'changes behaviour without tests for it',
57 isLikely: true,
58 ...where(behaviour?.at),
59 })
60 }
61
62 if (jev.hardToRead >= EFFORTFUL) {
63 const isHard = jev.hardToRead >= HARD
64 flags.push({
65 key: 'readability',
66 label: 'hard to follow',
67 said: isHard ? 'hard to follow' : 'parts take effort to follow',
68 isLikely: isHard,
69 ...where(jev.hardestAt),
70 })
71 }
72
73 if ((find('mixed')?.value ?? 0) >= LIKELY) {
74 const groups = groupsOf(blast.files)
75 flags.push({
76 key: 'mixed',
77 label: 'mixes changes',
78 said: 'mixes unrelated changes',
79 isLikely: true,
80 ...(groups.length > 1 ? { groups } : {}),
81 })
82 }
83
84 return flags
85}
86
87export const groupsLine = (groups: { name: string; files: number }[]) =>
88 groups.map(({ name, files }) => `${name} (${files} ${files === 1 ? 'file' : 'files'})`).join(' · ')
89
90/** A fired signal as one line of the Why list: what and where, never a percentage. */
91export function reasonOf(flag: Flag) {
92 if (flag.key === 'mixed') {
93 return `Jev: ${flag.said}${flag.groups === undefined ? '' : `: ${groupsLine(flag.groups)}`}; splitting it would help`
94 }
95
96 return `Jev: ${flag.said}${flag.at === undefined ? '' : `, mostly in ${placeText(flag.at)}`}`
97}
98
99/** The risk topics Jev checked and found nothing in, for one quiet line. */
100export const clearOf = (jev: Jev) =>
101 jev.status === 'judged' ? jev.judgments.filter(one => SENSITIVE.includes(one.key) && one.value < UNSURE).map(one => one.label) : []
102
103/** What Jev said about tests, when it is not already a flag. */
104export function testsNoteOf(jev: Jev) {
105 if (jev.status !== 'judged') return undefined
106
107 const value = (key: string) => jev.judgments.find(one => one.key === key)?.value
108 const tested = value('tested') ?? 0
109 const behaviour = value('behaviour') ?? 0
110
111 if (tested >= LIKELY) return 'Tests in the diff exercise the change.'
112 if (behaviour >= LIKELY && tested >= UNLIKELY) return 'Unclear whether the tests in the diff cover the change.'
113
114 return undefined
115}
116
117export function verdictOf(blast: Blast, jev: Jev) {
118 const reasons: string[] = []
119 const tally = { size: 0, reach: 0, risk: 0, quality: 0 }
120 const add = (kind: keyof typeof CAPS, weight: number, reason: string) => {
121 tally[kind] = Math.min(CAPS[kind], tally[kind] + weight)
122 reasons.push(reason)
123 }
124
125 if (blast.lines >= MANY_LINES) add('size', 2, `${blast.lines} lines changed`)
126 else if (blast.lines >= SOME_LINES) add('size', 1, `${blast.lines} lines changed`)
127
128 if (blast.files.length >= MANY_FILES) add('size', 2, `${blast.files.length} files changed`)
129 else if (blast.files.length >= SOME_FILES) add('size', 1, `${blast.files.length} files changed`)
130
131 if (blast.areas.length >= MANY_AREAS) add('size', 1, `spans ${blast.areas.length} areas: ${blast.areas.join(', ')}`)
132
133 const used = blast.widest?.usedBy ?? 0
134
135 if (blast.widest !== undefined && used >= WIDE_USE) add('reach', 2, `${blast.widest.path} is used by ${used} other files`)
136 else if (blast.widest !== undefined && used >= SOME_USE) add('reach', 1, `${blast.widest.path} is used by ${used} other files`)
137
138 if (blast.risky.length > 0) add('reach', 1, `touches build, config or schema: ${blast.risky.join(', ')}`)
139
140 for (const flag of flagsOf(blast, jev)) {
141 const isRisk = SENSITIVE.includes(flag.key)
142 const weight = (isRisk || flag.key === 'readability') && flag.isLikely ? 2 : 1
143
144 add(isRisk ? 'risk' : 'quality', weight, reasonOf(flag))
145 }
146
147 // Consequence is judged apart from size: a small change to sensitive code is never a pass.
148 const sensitive = blast.files.filter(file => SENSITIVE_PATH.test(file.path)).map(file => file.path)
149 const security = jev.status === 'judged' ? jev.judgments.find(one => one.key === 'security')?.value ?? 0 : 0
150
151 if (sensitive.length > 0) reasons.push(`changes sensitive code: ${sensitive.join(', ')}`)
152 if (security >= LIKELY) reasons.push('held for review because Jev judges it likely to touch security')
153
154 const points = tally.size + tally.reach + tally.risk + tally.quality
155 const bySize: Verdict = points >= HOLD_AT ? 'hold' : points >= READ_AT ? 'read' : 'pass'
156 const verdict: Verdict = security >= LIKELY ? 'hold' : sensitive.length > 0 && bySize === 'pass' ? 'read' : bySize
157
158 return { verdict, points, reasons }
159}
160hooks/hunks.ts 72 lines1import type { Place } from '../types'
2
3/** A Choice takes at most 255 options; one is kept for "none of these". */
4export const MOST_HUNKS = 254
5
6const FILE_HEADER = /^diff --git "?a\/.+"? "?b\/(.+?)"?$/
7const NEW_FILE = /^\+\+\+ new file (.+)$/
8const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
9
10export type Hunk = Place & { id: string; line: number }
11
12const idOf = (at: number) => `H${String(at + 1).padStart(2, '0')}`
13
14/**
15 * The patch with each hunk tagged `H01| `, `H02| ` ... at its header, in diff order, and where each one is: its file
16 * and the first changed line on the new side. A new file sent whole is one hunk at line 1. Hunks past `MOST_HUNKS`
17 * stay untagged.
18 */
19export function tagHunks(patch: string): { tagged: string; hunks: Hunk[] } {
20 const hunks: Hunk[] = []
21 let path: string | undefined
22 let line = 0
23 let open: Hunk | undefined
24
25 const tagged = patch.split('\n').map(text => {
26 const file = FILE_HEADER.exec(text)?.[1] ?? NEW_FILE.exec(text)?.[1]
27
28 if (file !== undefined) {
29 path = file
30 open = undefined
31
32 if (!text.startsWith('+++ new file ') || hunks.length >= MOST_HUNKS) {
33 return text
34 }
35
36 hunks.push({ id: idOf(hunks.length), path, line: 1 })
37
38 return `${idOf(hunks.length - 1)}| ${text}`
39 }
40
41 const header = HUNK_HEADER.exec(text)
42
43 if (header !== null && path !== undefined) {
44 line = Number(header[1])
45
46 if (hunks.length >= MOST_HUNKS) {
47 open = undefined
48
49 return text
50 }
51
52 open = { id: idOf(hunks.length), path, line }
53 hunks.push(open)
54
55 return `${open.id}| ${text}`
56 }
57
58 if (open !== undefined && (text.startsWith('+') || text.startsWith('-'))) {
59 open.line = line
60 open = undefined
61 }
62
63 if (text.startsWith(' ') || text.startsWith('+')) {
64 line += 1
65 }
66
67 return text
68 })
69
70 return { tagged: tagged.join('\n'), hunks }
71}
72types/index.d.ts 115 lines1export type ChangedFile = {
2 path: string
3 added: number
4 removed: number
5 isNew: boolean
6 usedBy?: number
7}
8
9export type Blast = {
10 files: ChangedFile[]
11 lines: number
12 areas: string[]
13 risky: string[]
14 widest?: ChangedFile
15}
16
17/** A file, and the changed line Jev points at in it when one hunk stands out. */
18export type Place = { path: string; line?: number }
19
20/** `at`: where Jev places the statement, when it is fairly sure of a file. */
21export type Judgment = {
22 key: string
23 label: string
24 value: number
25 at?: Place
26}
27
28/**
29 * `tookMs`: how long Jev took to answer, and `tokens`: the input tokens it billed, both shown in the pane's
30 * diagnostics. `hardestAt`: the place hardest to follow.
31 */
32export type Jev =
33 | { status: 'off' }
34 | { status: 'failed'; note: string; tookMs?: number }
35 | { status: 'judged'; judgments: Judgment[]; hardToRead: number; hardestAt?: Place; tookMs?: number; tokens?: number }
36
37/** A Jev signal that fired, with where to look: what the pane, the reasons and Start with all show. */
38export type Flag = {
39 key: string
40 label: string
41 said: string
42 isLikely: boolean
43 at?: Place
44 groups?: { name: string; files: number }[]
45}
46
47export type Verdict = 'pass' | 'read' | 'hold'
48
49/** Which change a triage judged: the commit's scope, its folder, and the paths its `git add` named. */
50export type Subject = { scope: Scope; cwd?: string; paths?: string[] }
51
52export type Triage = {
53 fingerprint: string
54 subject?: Subject
55 verdict: Verdict
56 points: number
57 reasons: string[]
58 blast: Blast
59 jev: Jev
60 isPatchCut: boolean
61 assessedAt?: number
62 /** The change moved on after this assessment: Claude edited a file, or an approval found it changed. */
63 isEdited?: boolean
64 /** The shell command Claude ran to commit, when the triage came from one: approving asks Claude to run it again. */
65 command?: string
66}
67
68export type Scope = 'tree' | 'tracked' | 'staged'
69
70export type Finding = {
71 priority: string
72 title: string
73 at: string
74 body: string
75}
76
77export type Review =
78 | { status: 'idle' }
79 | { status: 'running'; covers: string; tookMs: number; doing?: string; brief?: string }
80 | {
81 status: 'done'
82 covers: string
83 summary: string
84 findings: Finding[]
85 tookMs: number
86 isClean: boolean
87 isTimedOut?: boolean
88 /** Claude edited a file after the review; set by the Edit and Write hooks. */
89 isEdited?: boolean
90 fingerprint?: string
91 /** subjectKey of the triage the review was run on; a later triage of another change hides its findings. */
92 subject?: string
93 brief?: string
94 }
95
96export type TurnTally = {
97 lines: number
98 files: string[]
99}
100
101declare module 'claude-code' {
102 interface PluginState {
103 'triage-gate': {
104 triage: Triage | null
105 approved: string[]
106 review: Review
107 turn: TurnTally
108 sizeLevel: number
109 asks: string[]
110 /** The pane's Detailed signals section is open. */
111 details: boolean
112 }
113 }
114}
115