SLOPSHOPPER

triage-gate

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…

newpaneguardcommandtoastprompt
v0.9.0no licenseupdated 2026-10-13a-pavithraa/claude-mods/plugins/triage-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · triage-gate
│ ┃ Triage ✕ › fix the failing auth test and add an audit log call │ ┃ ◐ Review recommended │ ┃ Claude's commits go through; read the files ⏺ Read(src/auth.ts) │ ┃ below before you build on this. ⎿ Read 6 lines │ ┃ 2 files · +14 −3 lines · 1 area: src ⏺ Update(src/auth.ts) │ ┃ Assessed with local checks only: size, ⎿ Added 2 lines, removed 1 line │ ┃ spread and file paths. Add a TypeSafe key ⏺ Bash(bun test) │ ┃ for Jev. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Run Codex review ] [ Refresh assessment ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Why ✻ Worked for 42s · done 4:20 PM │ ┃ · changes sensitive code: src/auth.ts, │ ┃ src/auth.test.ts › /gate │ ┃ ⎿ triage-gate: Review recommended. 2 files · +14 −3 lines · 1 area │ ┃ Start with ⎿ triage-gate: - changes sensitive code: src/auth.ts, src/auth.tes │ ┃ src/auth.ts · sensitive code: auth, secrets ⎿ triage-gate: The Triage pane lists the files to read first; /gat │ ┃ src/auth.test.ts · sensitive code: auth, sec │ ┃ │ ┃ ▸ Detailed signals │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Triage
◐ Review recommended Claude's commits go through; read the files below before you build on this. 2 files · +14 −3 lines · 1 area: src Assessed with local checks only: size, spread and file paths. Add a TypeSafe key for Jev. [ Run Codex review ] [ Refresh assessment ] Why · changes sensitive code: src/auth.ts, src/auth.test.ts Start with src/auth.ts · sensitive code: auth, secrets or permissions src/auth.test.ts · sensitive code: auth, secrets or permissi ▸ Detailed signals
README

triage-gate

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:

  • No review trigger: nothing fired. This is not a claim that the change is safe.
  • Review recommended: the commit goes through, with a toast saying why to read it.
  • Hold: review before commit: the commit is refused until you approve it.

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.

Source 11 files
hooks/register.tsx 559 lines
1import { 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}
559
hooks/blast.ts 233 lines
1import 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}
233
hooks/guide.ts 38 lines
1import 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}
38
hooks/jev.ts 240 lines
1import 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}
240
hooks/pane.tsx 314 lines
1import 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}
314
hooks/report.ts 139 lines
1import 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}
139
hooks/review.ts 111 lines
1import 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}
111
hooks/shell.ts 229 lines
1// 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}
229
hooks/verdict.ts 160 lines
1import 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}
160
hooks/hunks.ts 72 lines
1import 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}
72
types/index.d.ts 115 lines
1export 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