Runs your tests after Claude edits code and makes the call. Off by default.

<img src="../../brand/pixel-icons/svg/umpire.svg" width="64" height="64" alt="">
Runs your tests after Claude edits code and makes the call: passed and failed counts above the prompt, a toast when it flips. Off by default.
umpire: 142 passed · 2 failed · auth.test.ts, user.test.ts · 8.3 s
/plugin install umpire --marketplace modmyclaude/claude-code-mods
Type /umpire on. From then on, at the end of every turn in which Claude changed files with its own tools (Edit, Write, NotebookEdit), umpire runs your project's tests once, and:
+2 more past that) and how long it took, in the error colour when something fails. While the tests run it says so.tests 142/144./umpire show opens the last run: the call, a mark per run for the last ten, the failing names, and the end of the output. tell Claude puts the failing names and the output's last 30 lines in your prompt box, after anything you typed, with anything that looks like a secret redacted. You decide whether to send it.package.json, pyproject.toml, pytest.ini, go.mod or Cargo.toml. Claude editing one anywhere in the project, or anything changing one at the project root (a shell command, you), stops the runs that start on their own, and the line says which file. They wait until you start a run with /umpire run or run again, so a test command Claude just changed waits for you to start it.It finds the test command once per session: your setting, else the one you set for this project with /umpire command, else package.json's test script (through pnpm, yarn, bun or npm, by lockfile), pytest, go test ./... or cargo test. It understands the summaries of Jest, Vitest, pytest, go test and cargo test; for anything else, the exit code makes the call.
| Command | What it does |
|---|---|
/umpire on | Run the tests after each turn that changes files (only when you type it) |
/umpire off | Stop |
/umpire run | Run them now (only when you type it) |
/umpire show | The last run, in a pane |
/umpire command <cmd> | Set this project's test command, as you would type it (only when you type it) |
/umpire tell | Put the failures in your prompt box (only when you type it) |
"Only when you type it" means typed by you in the terminal, the desktop app or Remote Control. A program you run that drives Claude Code through its SDK counts as you too, since the desktop app sends that way; another mod, a peer session or Claude running the command is refused.
Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
/umpire command says it is off; switched on again, /umpire on (or the setting below) decides, as before. /config or the modmyclaude hub flips it, live. It runs commands, so another mod cannot switch it on; anyone can switch it off./umpire on. Off by default.CI=1 and colour off so test runners do not wait in watch mode, stopped at the time limit. A command you typed (the setting or /umpire command) runs through sh as you wrote it. Nothing else.package.json, pyproject.toml, pytest.ini, go.mod and Cargo.toml, and whether a lockfile or tests/ exists, to find the command and to see when one of those files changes. The paths of the files Claude's tools edit, to hold the runs when one of them is such a file./umpire run covers the rest.-v names failing tests but counts no passes.output cut at 4 MB and may have no counts.This mod was built from the Umpire prompt.
More mods: the full catalogue.
hooks/register.tsx 427 lines1// umpire: runs your tests after Claude edits code and makes the call. Off until
2// /umpire on (or the setting). At the end of a turn in which Claude changed
3// files with its own tools, it runs the one test command, in the project root,
4// with a time limit, and shows passed and failed counts with the first failing
5// names above the prompt; a toast only when the result flips. One run at a
6// time: a turn that ends mid-run queues exactly one more. The run starts off
7// the turn's clock, never inside a hook's wait. When a file that says what the
8// tests run (package.json and the like) changes, by Claude's tools at any depth
9// or by anything at the root, the runs that start on their own wait until you
10// start one. It changes no file and sends nothing: "tell Claude" fills your
11// prompt box, redacted, for you to send.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { UmpireRun } from '../types'
17import { redact } from './redact'
18import { HOLD_FILES, bandWords, changedFile, clean, detect, heldWords, holdFile, namesWords, parseRun, projectKey, shellArgv, statusWords, tail, tellText } from './tests'
19import type { Project } from './tests'
20
21const isOnSet = atom({ plugin: 'umpire', key: 'isOn' } as const, null as boolean | null)
22const isRunningNow = atom({ plugin: 'umpire', key: 'isRunning' } as const, false)
23const runs = atom({ plugin: 'umpire', key: 'runs' } as const, [] as UmpireRun[])
24const heldBy = atom({ plugin: 'umpire', key: 'heldBy' } as const, null as string | null)
25
26const PANE = 'umpire'
27/** Origins that are the person: typed, the phone bridge, the desktop app's box. */
28const PERSON = new Set(['composer', 'bridge', 'sdk'])
29export const KEEP_RUNS = 10
30export const KEEP_CHARS = 1_000_000
31const FILE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
32/** Runners told they are not in a terminal: no watch mode, no colour. */
33const RUN_ENV = { CI: '1', NO_COLOR: '1', FORCE_COLOR: '0' }
34
35/** `isEnabled`: the switch (on unless set off); `startOn`: run in every session without /umpire on. */
36type Settings = { isEnabled: boolean; startOn: boolean; command: string; maxSeconds: number }
37
38export function readSettings(options: Record<string, unknown>): Settings {
39 const s = typeof options.maxSeconds === 'number' && Number.isFinite(options.maxSeconds) ? Math.min(600, Math.max(5, Math.round(options.maxSeconds))) : 120
40 const testCommand = typeof options.command === 'string' ? options.command.trim().slice(0, 500) : ''
41 return { isEnabled: options.enabled !== false, startOn: options.startOn === true, command: testCommand, maxSeconds: s }
42}
43
44const OFF_TEXT = 'Umpire is off. /mods or /config turns it on.'
45
46// What lives only as long as this copy of the module: the settings, whether this turn changed a
47// file, the run in flight and the one queued behind it, the detected command, the runs' output,
48// and the hold files' text when the person last started a run (or turned umpire on).
49let settings: Settings = readSettings({})
50let isTurnChanged = false
51let isRunning = false
52let isQueued = false
53let baseline: Record<string, string | null> | null = null
54let detected: { root: string; argv: string[] | null } | null = null
55const outputs = new Map<number, string>()
56let isNoCommandSaid = false
57
58function debug($: EngineInterface, text: string): void {
59 $.ui.log(`umpire: ${text}`, { to: 'debug' })
60}
61
62/** Running: the switch on, and /umpire on typed this session (else the start setting). */
63async function isOn($: EngineInterface): Promise<boolean> {
64 return settings.isEnabled && ((await read($, isOnSet)) ?? settings.startOn)
65}
66
67/** Keeps one run's output, dropping the oldest past ten runs or a million characters. */
68export function keepOutput(store: Map<number, string>, at: number, output: string): void {
69 store.set(at, output)
70 let chars = 0
71 for (const text of store.values()) chars += text.length
72 while (store.size > KEEP_RUNS || chars > KEEP_CHARS) {
73 const oldest = store.keys().next().value
74 if (oldest === undefined) break
75 chars -= store.get(oldest)?.length ?? 0
76 store.delete(oldest)
77 }
78}
79
80/** What the project folder says about its tests. */
81async function projectAt($: EngineInterface, root: string): Promise<Project> {
82 const at = (name: string) => `${root.replace(/\/+$/, '')}/${name}`
83 const text = (name: string) => $.fs.read(at(name)).catch(() => undefined)
84 const exists = (name: string) => $.fs.exists(at(name)).catch(() => false)
85 const lockfiles: string[] = []
86 for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock', 'package-lock.json']) if (await exists(name)) lockfiles.push(name)
87 const [packageJson, pyproject] = await Promise.all([text('package.json'), text('pyproject.toml')])
88 return {
89 ...(packageJson === undefined ? {} : { packageJson }),
90 ...(pyproject === undefined ? {} : { pyproject }),
91 lockfiles,
92 pytestIni: await exists('pytest.ini'),
93 hasTestsDir: await exists('tests'),
94 goMod: await exists('go.mod'),
95 cargoToml: await exists('Cargo.toml'),
96 }
97}
98
99/** The command to run: the setting, else the one set for this project, else found once from its files. */
100async function commandFor($: EngineInterface, root: string): Promise<string[] | null> {
101 if (settings.command !== '') return shellArgv(settings.command)
102 const stored = await $.store.get(`command:${projectKey(root)}`).catch(() => undefined)
103 if (typeof stored === 'string' && stored.trim() !== '') return shellArgv(stored)
104 if (detected === null || detected.root !== root) detected = { root, argv: detect(await projectAt($, root)) }
105 return detected.argv
106}
107
108/** The text of each hold file at the root, null where there is none. */
109async function holdTexts($: EngineInterface, root: string): Promise<Record<string, string | null>> {
110 const texts: Record<string, string | null> = {}
111 for (const name of HOLD_FILES) texts[name] = await $.fs.read(`${root.replace(/\/+$/, '')}/${name}`).catch(() => null)
112 return texts
113}
114
115/**
116 * One run of the tests, start to call; a run asked for while one goes is queued, once, as a run
117 * that starts on its own. A run the person starts clears the hold and finds the command again; one
118 * that starts on its own does not run while held, or when a hold file at the root is not what it
119 * was at the person's last run, or while the switch is off.
120 */
121async function runTests($: EngineInterface, isByPerson: boolean): Promise<void> {
122 // Switched off, nothing runs, not even a press on a pane drawn before.
123 if (!settings.isEnabled) return
124 if (isRunning) {
125 isQueued = true
126 return
127 }
128 isRunning = true
129 try {
130 // Off, a run that would start on its own does not (one queued or due when the switch went off).
131 if (!isByPerson && !(await isOn($))) return
132 const root = await $.session.root()
133 const texts = await holdTexts($, root)
134 if (isByPerson) {
135 baseline = texts
136 detected = null
137 await update($, heldBy, () => null)
138 } else {
139 const changed = baseline === null ? null : changedFile(baseline, texts)
140 if (changed !== null) await update($, heldBy, () => changed)
141 if ((await read($, heldBy)) !== null) return
142 }
143 const argv = await commandFor($, root)
144 if (argv === null) {
145 if (!isNoCommandSaid) $.ui.toast('umpire: no test command found here. /umpire command <cmd> sets one for this project.')
146 isNoCommandSaid = true
147 return
148 }
149 await update($, isRunningNow, () => true)
150 const started = await $.clock.now()
151 let output = ''
152 let run: UmpireRun
153 try {
154 const done = await $.process.run(argv, { cwd: root, env: RUN_ENV, timeoutMs: settings.maxSeconds * 1000 })
155 output = `${done.stdout}${done.stderr ? `\n${done.stderr}` : ''}`
156 const o = parseRun(output, done.exitCode)
157 // The engine keeps the first 4 MB of each stream: past that the summary line may be gone.
158 const isCut = done.isStdoutTruncated || done.isStderrTruncated
159 run = { at: await $.clock.now(), ms: (await $.clock.now()) - started, ...o, ...(isCut ? { isCut: true } : {}) }
160 } catch (error) {
161 const ms = (await $.clock.now()) - started
162 const isTimeout = ms >= settings.maxSeconds * 1000 * 0.9
163 // The engine names the call in the message (umpire: $.process.run: ...); the person needs the rest.
164 output = (error instanceof Error ? error.message : String(error)).replace(/^(?:[\w-]+: )?\$\.process\.run: /, '')
165 run = isTimeout
166 ? { at: await $.clock.now(), ms, status: 'timed-out', names: [] }
167 : { at: await $.clock.now(), ms, status: 'failed', names: [], error: clean(output).slice(0, 200) }
168 }
169 keepOutput(outputs, run.at, output)
170 const before = (await read($, runs)).at(-1)
171 await update($, runs, list => [...list, run].slice(-KEEP_RUNS))
172 // One that was going when the switch went off keeps its call, and says nothing.
173 if (!isByPerson && !(await isOn($))) return
174 $.ui.status(statusWords(run))
175 // A toast only when the call flips: passing to failing, or back.
176 const wasPassing = before === undefined ? undefined : before.status === 'passed'
177 const isPassing = run.status === 'passed'
178 if (wasPassing !== undefined && wasPassing !== isPassing) {
179 $.ui.toast(isPassing ? 'umpire: the tests pass again.' : `umpire: the tests started failing${run.names.length ? `: ${namesWords(run.names)}` : ''}.`)
180 }
181 } catch (error) {
182 debug($, String(error))
183 } finally {
184 isRunning = false
185 await update($, isRunningNow, () => false).catch(() => undefined)
186 if (isQueued) {
187 isQueued = false
188 $.clock.after(0, () => void runTests($, false))
189 }
190 }
191}
192
193/** A command as the person reads it: what they typed, not the shell wrapped around it. */
194function shown(argv: readonly string[]): string {
195 return argv[0] === '/bin/sh' && argv[1] === '-c' ? (argv[2] ?? '') : argv.join(' ')
196}
197
198async function openPane($: EngineInterface): Promise<boolean> {
199 try {
200 return (await $.ui.open({ id: PANE, title: 'umpire', focus: true, closeOnEscape: true })).isPlaced
201 } catch {
202 return false
203 }
204}
205
206/** Fills the prompt box with the failing names and the output's end, redacted; never sends. */
207async function tell($: EngineInterface): Promise<void> {
208 const last = (await read($, runs)).at(-1)
209 if (last === undefined || last.status === 'passed') return $.ui.toast('umpire: nothing failing to tell Claude about.')
210 const text = redact(tellText(last, outputs.get(last.at) ?? ''))
211 let draft = ''
212 try {
213 draft = (await $.prompt.read()).text
214 } catch {
215 draft = ''
216 }
217 try {
218 const filled = await $.prompt.fill({ text: draft.trim() === '' ? text : `\n\n${text}`, mode: draft.trim() === '' ? 'replace' : 'append' })
219 $.ui.toast(filled.isFilled ? 'umpire: the failures are in your prompt box. Send them when you want.' : 'umpire: the prompt box could not take the text.')
220 } catch {
221 $.ui.toast('umpire: the prompt box could not take the text.')
222 }
223}
224
225async function command($: EngineInterface, args: string, origin: string): Promise<void> {
226 const trimmed = args.trim()
227 const verb = (trimmed.split(/\s+/)[0] ?? '').toLowerCase()
228 const rest = trimmed.slice(verb.length).trim()
229 if (verb === 'off') {
230 await update($, isOnSet, () => false)
231 $.ui.status(undefined)
232 return $.ui.toast('umpire is off for this session.')
233 }
234 if (verb === '' || verb === 'show') {
235 if (!(await openPane($))) {
236 const last = (await read($, runs)).at(-1)
237 $.ui.toast(last ? bandWords(last, last.ms, settings.maxSeconds) : 'umpire: no run yet.')
238 }
239 return
240 }
241 // Turning it on, running, setting the command and filling the prompt box are the person's to do.
242 if (!PERSON.has(origin)) return $.ui.toast(`umpire: /umpire ${verb} runs only when you type it yourself.`)
243 if (verb === 'on') {
244 await update($, isOnSet, () => true)
245 // Turning it on takes the hold files as they are now, Claude's edits while it was off too: later changes hold the runs.
246 baseline = await holdTexts($, await $.session.root())
247 await update($, heldBy, () => null)
248 const argv = await commandFor($, await $.session.root())
249 return $.ui.toast(argv ? `umpire is on: \`${clean(shown(argv)).slice(0, 80)}\` after each turn that changes files.` : 'umpire is on, but no test command was found. /umpire command <cmd> sets one.')
250 }
251 if (verb === 'run') {
252 $.clock.after(0, () => void runTests($, true))
253 return $.ui.toast('umpire: running the tests.')
254 }
255 if (verb === 'command') {
256 const root = await $.session.root()
257 if (rest === '') return $.ui.toast('umpire: /umpire command <the command, as you would type it>.')
258 await $.store.set(`command:${projectKey(root)}`, rest.slice(0, 500))
259 isNoCommandSaid = false
260 return $.ui.toast(`umpire: this project's tests run with \`${clean(rest).slice(0, 80)}\`.`)
261 }
262 if (verb === 'tell') return tell($)
263 $.ui.toast('umpire: /umpire on, off, run, show, command <cmd>, or tell.')
264}
265
266export const register: Register = (on, options) => {
267 settings = readSettings(options)
268
269 // The switch (`enabled`, which /config and the modmyclaude hub flip): off, nothing runs and
270 // every command says so; on, /umpire on or the start setting decide, as before. The engine
271 // reloads the mod with the new value; this makes it so at once. Umpire runs commands, so only
272 // the person (the menu, the bridge) or the hub on their press switches it on; anyone may
273 // switch it off. Off clears the status line. A hold is never cleared by the switch.
274 on('config.set', { key: 'umpire.enabled' }, async ($, e, next) => {
275 if (e.value === true && e.origin.kind === 'plugin' && e.origin.name !== 'modmyclaude-hub') {
276 return { deny: 'umpire stays off: you switch it on in the /config menu or the modmyclaude hub' }
277 }
278 const set = await next(e)
279 if (set.deny === undefined && typeof set.value === 'boolean') {
280 settings = { ...settings, isEnabled: set.value }
281 if (!set.value) $.ui.status(undefined)
282 $.ui.invalidate('ui.render')
283 // Running again, with no snapshot of the hold files yet: they are taken as they are now.
284 if (baseline === null && (await isOn($))) baseline = await $.session.root().then(root => holdTexts($, root)).catch(() => null)
285 }
286 return set
287 })
288
289 on('session.start', async ($, e, next) => {
290 try {
291 await $.command.register({
292 name: 'umpire',
293 description: 'Runs your tests after Claude edits code: on, off, run, show, command, tell',
294 argumentHint: '[on | off | run | show | command <cmd> | tell]',
295 immediate: true,
296 })
297 if (await isOn($)) baseline = await holdTexts($, await $.session.root())
298 } catch (error) {
299 debug($, String(error))
300 }
301 return next(e)
302 })
303
304 // Claude's own file tools, once they ran: this turn changed a file, and a hold file anywhere holds the runs.
305 on('tool.call', async ($, e, next) => {
306 const ran = await next(e)
307 if (ran.deny === undefined && !ran.isError && FILE_TOOLS.has(String(e.tool))) {
308 isTurnChanged = true
309 const input = (e as unknown as { file_path?: unknown }).file_path
310 const path = typeof input === 'string' ? input : ''
311 const name = holdFile(path)
312 if (name !== null) {
313 const root = (await $.session.root().catch(() => '')).replace(/\/+$/, '')
314 await update($, heldBy, () => clean(path.startsWith(`${root}/`) ? path.slice(root.length + 1) : name))
315 }
316 }
317 return ran
318 })
319
320 on('turn.start', ($, e, next) => {
321 isTurnChanged = false
322 return next(e)
323 })
324
325 // A main turn that answered and changed files: one run, off the turn's clock.
326 on('turn.complete', async ($, e, next) => {
327 const done = await next(e)
328 if (e.agentId === undefined && e.reason === 'answer' && !e.isAborted && isTurnChanged) {
329 isTurnChanged = false
330 if (await isOn($)) $.clock.after(0, () => void runTests($, false))
331 }
332 return done
333 })
334
335 // The call above the prompt, under other mods' lines; nothing while off or before a first run.
336 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337 const below = await next(e)
338 if (e.surface !== 'terminal' && e.surface !== 'desktop') return below
339 if (!(await isOn($))) return below
340 const last = (await read($, runs)).at(-1)
341 const going = await read($, isRunningNow)
342 const held = await read($, heldBy)
343 if (last === undefined && !going && held === null) return below
344 const { Box, Text } = $.ui.resolve(e)
345 const words = going ? 'umpire: running the tests...' : held !== null ? heldWords(held) : bandWords(last!, last!.ms, settings.maxSeconds)
346 const color = going ? undefined : held !== null ? 'warning' : last!.status === 'passed' ? 'success' : 'error'
347 return (
348 <Box flexDirection="column">
349 {below}
350 <Text key="umpire" color={color} dimColor={going} wrap="truncate-end">
351 {words}
352 </Text>
353 </Box>
354 )
355 }).catch(($, e, next) => next(e))
356
357 // /umpire show: the last call, its failing names, the end of its output, and the history.
358 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
359 const { Box, Button, Code, Text } = $.ui.resolve(e)
360 if (!settings.isEnabled) {
361 return (
362 <Box flexDirection="column" paddingX={1}>
363 <Text key="off" dimColor>
364 {OFF_TEXT}
365 </Text>
366 </Box>
367 )
368 }
369 const list = await read($, runs)
370 const last = list.at(-1)
371 const held = await read($, heldBy)
372 const heldLine = held === null ? null : <Text key="held" color="warning">{heldWords(held)}</Text>
373 if (last === undefined) {
374 return (
375 <Box flexDirection="column" paddingX={1}>
376 {heldLine}
377 <Text key="none" dimColor>
378 No run yet. /umpire run runs the tests now.
379 </Text>
380 </Box>
381 )
382 }
383 const output = outputs.get(last.at)
384 return (
385 <Box flexDirection="column" paddingX={1}>
386 <Text key="call" bold color={last.status === 'passed' ? 'success' : 'error'}>
387 {bandWords(last, last.ms, settings.maxSeconds)}
388 </Text>
389 {heldLine}
390 <Box key="history" flexDirection="row">
391 <Text key="label" dimColor>
392 {'history '}
393 </Text>
394 {list.map((r, i) => (
395 <Box key={`h-${i}`} width={1}>
396 <Text color={r.status === 'passed' ? 'success' : 'error'}>{r.status === 'passed' ? '✓' : r.status === 'timed-out' ? '?' : '✗'}</Text>
397 </Box>
398 ))}
399 </Box>
400 {last.error ? <Text key="error">{`Could not run: ${last.error}`}</Text> : null}
401 {last.names.slice(0, 20).map((name, i) => (
402 <Text key={`name-${i}`}>{`✗ ${clean(name)}`}</Text>
403 ))}
404 {output ? <Code key="output" source={tail(output, 60)} /> : <Text key="gone" dimColor>The output is no longer kept: only the last ten runs are.</Text>}
405 <Box key="buttons" flexDirection="row" gap={2}>
406 <Button key="again" label="run again" onPress={() => $.clock.after(0, () => void runTests($, true))} />
407 {last.status === 'passed' ? null : <Button key="tell" label="tell Claude" onPress={() => void tell($)} />}
408 </Box>
409 </Box>
410 )
411 })
412
413 on('command.run', { command: 'umpire' }, async ($, e) => {
414 if (!settings.isEnabled) {
415 $.ui.toast(OFF_TEXT)
416 return {}
417 }
418 try {
419 await command($, e.args, e.origin.kind)
420 } catch (error) {
421 debug($, String(error))
422 $.ui.toast('umpire: that command failed, and nothing changed.')
423 }
424 return {}
425 })
426}
427hooks/redact.ts 59 lines1// Secret redaction: the same as crossroads's (hooks/logic.ts, "secret redaction"), pop-quiz's and
2// chapters's, copied so this mod installs on its own. Keep them in step: a fix to one is a fix to all.
3
4export const REDACTED = '[REDACTED]'
5
6// Token shapes with a fixed, recognisable prefix. Deliberately no "long hex"
7// or "long base64" rule: git SHAs, UUIDs and hashes would all match it.
8const TOKEN_SHAPES: readonly RegExp[] = [
9 /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
10 /\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|APKA)[A-Z0-9]{16}\b/g,
11 /\bgh[pousr]_[A-Za-z0-9]{30,255}\b/g,
12 /\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g,
13 /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g,
14 /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g,
15 /\bwhsec_[A-Za-z0-9]{16,}\b/g,
16 /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
17 /\bAIza[0-9A-Za-z_-]{35}\b/g,
18 /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g,
19 /\bglpat-[A-Za-z0-9_-]{20,}/g,
20 /\bhf_[A-Za-z0-9]{30,}\b/g,
21 /\bnpm_[A-Za-z0-9]{36}\b/g,
22 /\bSK[0-9a-f]{32}\b/g,
23 // A Slack or Discord webhook URL is itself the credential.
24 /\bhttps:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\/\S+/g,
25]
26
27// `password=...`, `API_KEY: "..."`, `"client_secret": "..."`: the name must END
28// in the keyword, so `max_tokens=5` and `author: Alex` are left alone.
29const ASSIGNMENT =
30 /\b([A-Za-z0-9_.-]*?(?:password|passwd|pwd|pass|passphrase|secret|secret_key_base|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credentials?))(["']?\s*[:=]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;&]+)/gi
31const AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/g
32// GitHub's `Authorization: token ...`: only after the header name, so "token authentication" stays.
33const TOKEN_HEADER = /(\bAuthorization\s*:\s*token\s+)[A-Za-z0-9._~+/=-]{12,}/gi
34const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi
35// `--password hunter2`, `--token=abc`: a secret-named flag and its value.
36const FLAG_SECRET =
37 /(\s--?(?:password|passwd|pass|pwd|token|secret|api[-_]?key|access[-_]?key|auth[-_]?token)[=\s]+)("[^"]*"|'[^']*'|\S+)/gi
38// `mysql -phunter2` (the value glued to -p), and `curl -u user:pass`.
39const MYSQL_P = /(\b(?:mysql|mysqldump|mariadb)\b[^\n]*?\s-p)(\S+)/g
40const USER_PASS = /((?:\s-u\s+|\s--user[=\s]+)[^\s:]+:)(\S+)/g
41// "my password is hunter2"
42const SAID_PASSWORD = /\b(password|passphrase|passwd)(\s+(?:is|was)\s+)(\S+)/gi
43
44/** Replaces anything that looks like a secret with a placeholder. */
45export function redact(text: string): string {
46 let out = text
47 for (const shape of TOKEN_SHAPES) out = out.replace(shape, REDACTED)
48 out = out.replace(ASSIGNMENT, (whole, name: string, sep: string, value: string) =>
49 value.includes(REDACTED) ? whole : `${name}${sep}${REDACTED}`,
50 )
51 out = out.replace(AUTH_HEADER, `$1 ${REDACTED}`).replace(TOKEN_HEADER, `$1${REDACTED}`)
52 out = out.replace(FLAG_SECRET, (whole, flag: string, value: string) => (value.includes(REDACTED) ? whole : `${flag}${REDACTED}`))
53 out = out.replace(MYSQL_P, `$1${REDACTED}`).replace(USER_PASS, `$1${REDACTED}`)
54 out = out.replace(SAID_PASSWORD, `$1$2${REDACTED}`)
55
56 return out.replace(URL_PASSWORD, `$1${REDACTED}@`)
57}
58
59hooks/tests.ts 173 lines1// The pure part of umpire: which test command a project uses, how a run's
2// output reads into counts and failing names, and the words drawn.
3
4/** What a project folder holds that says how its tests run. */
5export type Project = {
6 packageJson?: string
7 lockfiles: string[]
8 pytestIni: boolean
9 pyproject?: string
10 hasTestsDir: boolean
11 goMod: boolean
12 cargoToml: boolean
13}
14
15/** The test command, as argv, or null when nothing says how. */
16export function detect(p: Project): string[] | null {
17 if (p.packageJson !== undefined) {
18 let script: unknown
19 try {
20 script = (JSON.parse(p.packageJson) as { scripts?: { test?: unknown } }).scripts?.test
21 } catch {
22 script = undefined
23 }
24 // npm's placeholder fails on purpose: it is no test command.
25 if (typeof script === 'string' && script.trim() !== '' && !/no test specified/.test(script)) {
26 if (p.lockfiles.includes('pnpm-lock.yaml')) return ['pnpm', 'test']
27 if (p.lockfiles.includes('yarn.lock')) return ['yarn', 'test']
28 if (p.lockfiles.includes('bun.lockb') || p.lockfiles.includes('bun.lock')) return ['bun', 'run', 'test']
29 return ['npm', 'test']
30 }
31 }
32 if (p.pytestIni || /\[tool\.pytest/.test(p.pyproject ?? '') || (p.pyproject !== undefined && p.hasTestsDir)) return ['python3', '-m', 'pytest', '-q']
33 if (p.goMod) return ['go', 'test', './...']
34 if (p.cargoToml) return ['cargo', 'test']
35 return null
36}
37
38/** A command the person typed, run through the shell as they wrote it. */
39export function shellArgv(command: string): string[] {
40 return ['/bin/sh', '-c', command]
41}
42
43export type Outcome = {
44 status: 'passed' | 'failed' | 'timed-out'
45 passed?: number
46 failed?: number
47 total?: number
48 /** Failing tests or files, as the runner names them, each once. */
49 names: string[]
50}
51
52const unique = (names: string[]) => [...new Set(names.map(n => n.trim()).filter(Boolean))]
53
54/** A run's output read into counts and failing names; the exit code decides when no runner's summary is found. */
55export function parseRun(output: string, exitCode: number): Outcome {
56 const text = output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
57 const status = exitCode === 0 ? 'passed' : 'failed'
58 // jest: "Tests: 2 failed, 142 passed, 144 total"
59 const jest = /^Tests:\s+(.*\d+ total)\s*$/m.exec(text)
60 if (jest) {
61 const n = (word: string) => Number(new RegExp(`(\\d+) ${word}`).exec(jest[1]!)?.[1] ?? 0)
62 return { status, passed: n('passed'), failed: n('failed'), total: n('total'), names: unique([...text.matchAll(/^FAIL\s+(\S+)/gm)].map(m => m[1]!)) }
63 }
64 // vitest: " Tests 2 failed | 142 passed (144)"
65 const vitest = /^\s*Tests\s+(.*)\((\d+)\)\s*$/m.exec(text)
66 if (vitest) {
67 const n = (word: string) => Number(new RegExp(`(\\d+) ${word}`).exec(vitest[1]!)?.[1] ?? 0)
68 return { status, passed: n('passed'), failed: n('failed'), total: Number(vitest[2]), names: unique([...text.matchAll(/^\s*FAIL\s+(\S+)/gm)].map(m => m[1]!)) }
69 }
70 // pytest: "==== 2 failed, 142 passed in 8.30s ====" (or "142 passed in 1.2s")
71 const pytest = /^=+ (.*\d+ (?:passed|failed|error).*) in [\d.]+s(?: \([^)]*\))? =+$/m.exec(text)
72 if (pytest) {
73 const n = (word: string) => Number(new RegExp(`(\\d+) ${word}`).exec(pytest[1]!)?.[1] ?? 0)
74 const passed = n('passed')
75 const failed = n('failed') + n('errors?')
76 return { status, passed, failed, total: passed + failed, names: unique([...text.matchAll(/^(?:FAILED|ERROR) (\S+)/gm)].map(m => m[1]!)) }
77 }
78 // cargo: one "test result: FAILED. 142 passed; 2 failed; ..." per test binary
79 const cargo = [...text.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed/gm)]
80 if (cargo.length > 0) {
81 const passed = cargo.reduce((s, m) => s + Number(m[1]), 0)
82 const failed = cargo.reduce((s, m) => s + Number(m[2]), 0)
83 return { status, passed, failed, total: passed + failed, names: unique([...text.matchAll(/^---- (\S+) stdout ----$/gm)].map(m => m[1]!)) }
84 }
85 // go test: failing tests by name; packages that passed say "ok". No per-test count without -v.
86 const goFails = [...text.matchAll(/^\s*--- FAIL: (\S+)/gm)].map(m => m[1]!)
87 if (goFails.length > 0 || /^ok\s+\S+/m.test(text)) {
88 return { status, ...(goFails.length > 0 ? { failed: unique(goFails).length } : {}), names: unique(goFails) }
89 }
90 return { status, names: [] }
91}
92
93/** Controls and escapes out of output that is drawn or put in the prompt box; tabs as spaces. */
94export function clean(text: string): string {
95 return text
96 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
97 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
98 .replace(/\r(?!\n)/g, '\n')
99 .replace(/\t/g, ' ')
100 .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '�')
101}
102
103/** The last `n` lines of output, cleaned. */
104export function tail(output: string, n: number): string {
105 return clean(output).replace(/\n+$/, '').split('\n').slice(-n).join('\n')
106}
107
108function seconds(ms: number): string {
109 return ms < 10_000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms / 1000)} s`
110}
111
112/** The first three failing names, then "+N more"; a path keeps only its last part. */
113export function namesWords(names: readonly string[]): string {
114 const short = names.map(n => (n.includes('::') ? n : n.split('/').pop() ?? n))
115 return short.length <= 3 ? short.join(', ') : `${short.slice(0, 3).join(', ')} +${short.length - 3} more`
116}
117
118/** The band's line for an outcome; `isCut` when the engine stopped keeping the output at 4 MB. */
119export function bandWords(o: Outcome & { isCut?: boolean }, ms: number, maxSeconds: number): string {
120 if (o.status === 'timed-out') return `umpire: tests timed out after ${maxSeconds} s`
121 const parts = ['umpire:']
122 const counts: string[] = []
123 if (o.passed !== undefined) counts.push(`${o.passed} passed`)
124 if (o.failed !== undefined && (o.failed > 0 || o.passed !== undefined)) counts.push(`${o.failed} failed`)
125 if (counts.length === 0) counts.push(o.status === 'passed' ? 'tests passed' : 'tests failed')
126 parts.push(counts.join(' · '))
127 const line = [parts.join(' '), ...(o.names.length > 0 && o.status === 'failed' ? [namesWords(o.names)] : []), seconds(ms), ...(o.isCut ? ['output cut at 4 MB'] : [])]
128 return line.join(' · ')
129}
130
131/** The status line's short form: "tests 142/144", or "tests pass" / "tests fail". */
132export function statusWords(o: Outcome): string {
133 if (o.status === 'timed-out') return 'tests timed out'
134 if (o.passed !== undefined && o.total !== undefined && o.total > 0) return `tests ${o.passed}/${o.total}`
135 return o.status === 'passed' ? 'tests pass' : 'tests fail'
136}
137
138/** What "tell Claude" puts in the prompt box: the failing names and the output's last lines. */
139export function tellText(o: Outcome & { isCut?: boolean }, output: string): string {
140 const names = o.names.length > 0 ? `The tests are failing: ${o.names.slice(0, 20).join(', ')}.` : 'The tests are failing.'
141 const which = o.isCut ? 'The run printed more than 4 MB; the last lines kept:' : 'The last lines of the run:'
142 return `${names} ${which}\n\n\`\`\`\n${tail(output, 30)}\n\`\`\``
143}
144
145/** Files that say what a test command runs. A change to one holds the runs that start on their own. */
146export const HOLD_FILES = ['package.json', 'pyproject.toml', 'pytest.ini', 'go.mod', 'Cargo.toml'] as const
147
148/** The hold file a path names, in its usual spelling, or null; any case, since macOS's disks ignore it. */
149export function holdFile(path: string): string | null {
150 const name = (path.split(/[\\/]/).pop() ?? '').toLowerCase()
151 return HOLD_FILES.find(f => f.toLowerCase() === name) ?? null
152}
153
154/** The first hold file whose text is not what it was at the last run the person started; null when none. */
155export function changedFile(before: Readonly<Record<string, string | null>>, now: Readonly<Record<string, string | null>>): string | null {
156 return HOLD_FILES.find(f => before[f] !== now[f]) ?? null
157}
158
159/** The band's line while the runs that start on their own wait for the person. */
160export function heldWords(name: string): string {
161 return `umpire: held, ${name} changed · /umpire run to go on`
162}
163
164/** FNV-1a over the project root: a key for its stored command that does not spell the path. */
165export function projectKey(root: string): string {
166 let h = 0x811c9dc5
167 for (let i = 0; i < root.length; i++) {
168 h ^= root.charCodeAt(i)
169 h = Math.imul(h, 0x01000193) >>> 0
170 }
171 return h.toString(16).padStart(8, '0')
172}
173types/index.d.ts 35 lines1// What umpire keeps in $.state for the session: whether it is on (when set this
2// session), whether a run is going, the last runs' calls, and the file whose
3// change holds the runs that start on their own. The runs' output lives in
4// the module's memory; the command set per project, in its store.
5
6export type UmpireRun = {
7 /** When it ended, ms since the epoch, and how long it took. */
8 at: number
9 ms: number
10 status: 'passed' | 'failed' | 'timed-out'
11 passed?: number
12 failed?: number
13 total?: number
14 /** Failing tests or files, each once. */
15 names: string[]
16 /** Why it could not run, when it could not. */
17 error?: string
18 /** The runner printed more than the engine keeps (4 MB): the counts may be missing. */
19 isCut?: boolean
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 umpire: {
25 /** Set by /umpire on or off this session; null follows the setting. */
26 isOn: boolean | null
27 isRunning: boolean
28 /** The last ten runs, oldest first. */
29 runs: UmpireRun[]
30 /** A file that says what the tests run changed: runs that start on their own wait until the person starts one. */
31 heldBy: string | null
32 }
33 }
34}
35