SLOPSHOPPER

umpire

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

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/umpire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · umpire
│ ┃ umpire ✕ › fix the failing auth test and add an audit log call │ ┃ No run yet. /umpire run runs the tests │ ┃ now. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /umpire │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · umpire
No run yet. /umpire run runs the tests now.
README

<img src="../../brand/pixel-icons/svg/umpire.svg" width="64" height="64" alt="">

Umpire

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

Install

/plugin install umpire --marketplace modmyclaude/claude-code-mods

Use it

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:

  • A line above the prompt makes the call: passed and failed counts, the first three failing tests or files (+2 more past that) and how long it took, in the error colour when something fails. While the tests run it says so.
  • A toast only when the call flips: the tests started failing, or they pass again. Never on every run.
  • The status line keeps the count: 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.
  • A hold when a file that says what the tests run changes: 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.

CommandWhat it does
/umpire onRun the tests after each turn that changes files (only when you type it)
/umpire offStop
/umpire runRun them now (only when you type it)
/umpire showThe 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 tellPut 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.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

  • On: on by default. Off, Umpire runs nothing and draws nothing, and every /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.
  • On from the start: run in every session without /umpire on. Off by default.
  • Test command: the command to run, as you would type it. Empty: found from the project.
  • Time limit (seconds): 120 by default; a longer run is stopped and called timed out.

What it can touch

  • Runs: the one test command, in the project root, with 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.
  • Reads: the project's 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.
  • Keeps: the last ten runs' output, a million characters at most, in the mod's memory only. The command you set per project in its own store, keyed by a hash of the project's path.
  • Writes: its session state (on or off, the runs' calls, the hold), the line, the status line, toasts, and your prompt box when you press tell Claude, never sent.
  • Network, model calls: none of its own. Your tests may do anything your tests do.

Limits

  • Changes a shell command made (a formatter, a codegen script) do not trigger a run; only Claude's own file tools do. /umpire run covers the rest.
  • One run at a time: a turn that ends during a run queues exactly one more, run when the first is done.
  • Counts come from the test runner's summary line; go test without -v names failing tests but counts no passes.
  • The run's output is shown as the runner printed it, without colour. Long output keeps its end. Past 4 MB the engine stops keeping it: the call then says output cut at 4 MB and may have no counts.
  • The hold covers the files that say what the command runs. The tests themselves are code, and Claude may have written them: umpire runs them as your test runner would, so turn it on only where you would run the tests yourself. A command you set that reads another file (a Makefile) is not held when that file changes.
  • The line, the pane and the presses are checked through the engine's test kit on the terminal and desktop surfaces, with the test runner stood in for.

Built from a prompt

This mod was built from the Umpire prompt.

More mods: the full catalogue.

Source 4 files
hooks/register.tsx 427 lines
1// 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}
427
hooks/redact.ts 59 lines
1// 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
59
hooks/tests.ts 173 lines
1// 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}
173
types/index.d.ts 35 lines
1// 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