SLOPSHOPPER

spotcheck

Checks the files, line numbers and symbols a subagent cites before Claude repeats the report.

newpaneguardcommandprocess
v0.1.0MITupdated 2026-10-04ivanvyd/ground-rules/plugins/spotcheck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotcheck
│ ┃ spotcheck ✕ › fix the failing auth test and add an audit log call │ ┃ Mode: notify │ ┃ No subagent report with references yet. ⏺ 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 │ │ › /spotcheck │ ⎿ spotcheck: spotcheck pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · spotcheck
Mode: notify No subagent report with references yet.
README

spotcheck

Checks the files, line numbers and symbols a subagent cites before Claude repeats the report.

spotcheck · scout-api: 14/14
spotcheck · scout-db: 9/9
spotcheck · scout-auth: 11 refs, 2 don't resolve

A subagent can cite src/auth/session.ts:212 for a file that has 180 lines. Claude then repeats the claim as fact. The check is cheap: a file lookup, a line count and a git grep.

Install

claude plugin install spotcheck@ground-rules

Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.

What it does

When a subagent finishes, spotcheck reads its final report and checks what it cites. It calls no model and blocks nothing.

The report comes from the agent's last answer. In auto mode, Claude Code has a subagent report through its SubagentHandback tool instead, so spotcheck reads the message of that call too. A hand-back is not counted as a tool call the agent made.

What it reads from the report

  • Paths, with or without a line: path, path:12, path:12-30, path:12:5, path#L12, path#L12-L30. Windows D:\x\y.ts, D:/x/y.ts, Git Bash /d/x/y.ts and UNC paths work. URLs, version numbers, Node.js, domain names and email addresses do not count.
  • Backticked identifiers of four characters or more that look like code: refreshGrant(, RefreshGrant, refresh_grant.
  • At most 40 paths and 10 symbols per report.

How it checks

ReferenceCheckResult
A pathExists under the agent's worktree, then under the session root.ok, or missing
A path relative to a subfoldergit ls-files for the one tracked file whose path ends with it.ok, or unchecked when several match
A line or rangeThe file's line count, CRLF-safe, for files up to 4 MiB.ok, or past the end of the file
A symbolgit grep -F -n -I -e <symbol> --, five second timeout.found, missing, or unchecked
A bare name with no lineLooked up like a path. If it is nowhere, it is not a claim.unchecked
The reportThe agent made no tool call but cites files.flagged

A timeout, a repository without git, a file over 4 MiB and an ambiguous name read as unchecked, never as missing.

What you and Claude see

  • One dim line when the agent finishes, in notify mode.
  • A note on the Agent tool result when something failed, in notify and quiet mode: spotcheck: in scout-api's report, src/auth/session.ts:212 is past the end of the file (180 lines) and \refreshGrant\ has no match. Check these before relaying. In a spike, Claude quoted the note 5 times in 5 runs.
  • /spotcheck opens a pane with each report, and per reference the status.

Background agents get the line only. A note attached to a background agent's task notification was read in 1 of 2 spike runs, so the mod does not rely on it.

Modes

/spotcheck notify|quiet|off, or the mode option. notify is the default.

What it reads, runs and stores

Hookssession.start, command.run, classic.PostToolUse (for a subagent's tool calls and its hand-back), turn.complete, tool.call on Agent only, and ui.render for the pane.
ReadsA finished subagent's final text, parsed and dropped. File existence, size and contents of the cited files.
Runsgit grep -F and git ls-files, in the agent's tree.
StoresThe verdicts of the last 20 reports: references and statuses, not text.
NeverA model, the network, a block or a denial.

The tool.call hook matches Agent and nothing else, so it does not touch isolation for Bash.

Limits

  • It cannot tell that an agent edited a file after citing it.
  • If an isolated worktree was removed, it checks the main tree and labels the result.
  • English reports only: it finds references by their shape, not by words.
  • It checks that something exists, not that it means what the report says.
  • On a very large repository git grep can time out; those symbols show as unchecked.
Source 6 files
hooks/register.tsx 177 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Mode, Report } from '../types'
5import { checkRefs, type Io } from './check'
6import { extractRefs } from './parse'
7import { isPresentation, label } from './privacy'
8import { HANDBACK_TOOL, agentIdOf, claudeNote, fileRow, handbackMessage, needsAttention, outcome, summaryLine } from './report'
9
10const PANE = 'spotcheck'
11const MODES: readonly Mode[] = ['notify', 'quiet', 'off']
12const KEPT_REPORTS = 20
13const GREP_TIMEOUT_MS = 5_000
14
15const mode = atom({ plugin: 'spotcheck', key: 'mode' } as const, 'notify')
16const reports = atom({ plugin: 'spotcheck', key: 'reports' } as const, [])
17const activity = atom({ plugin: 'spotcheck', key: 'activity' } as const, {})
18const openId = atom({ plugin: 'spotcheck', key: 'openId' } as const, '')
19
20const parseMode = (text: string): Mode | undefined => MODES.find(known => known === text.trim().toLowerCase())
21
22/** The checks' view of the machine: files through `$.fs`, symbols through `git grep`. */
23function makeIo($: EngineInterface): Io {
24  return {
25    exists: path => $.fs.exists(path),
26    size: async path => (await $.fs.stat(path)).size,
27    read: path => $.fs.read(path),
28    grep: async (symbol, cwd) => {
29      const result = await $.process
30        .run(['git', 'grep', '-F', '-n', '-I', '-e', symbol, '--'], { cwd, timeoutMs: GREP_TIMEOUT_MS })
31        .catch(() => undefined)
32      if (result === undefined) return 'unchecked'
33      return result.exitCode === 0 ? 'found' : result.exitCode === 1 ? 'missing' : 'unchecked'
34    },
35    findByName: async (path, cwd) => {
36      const pattern = path.replace(/\\/g, '/').replace(/^\.\//, '')
37      const result = await $.process
38        .run(['git', 'ls-files', '-z', '--', `:(glob)**/${pattern}`], { cwd, timeoutMs: GREP_TIMEOUT_MS })
39        .catch(() => undefined)
40      if (result === undefined || result.exitCode !== 0) return []
41      return [...new Set(result.stdout.split('\0').filter(name => name !== ''))].map(name => `${cwd.replace(/[\\/]+$/, '')}/${name}`)
42    },
43  }
44}
45
46/** The agent's description, cut and scrubbed, or a short form of its id. */
47async function agentLabel($: EngineInterface, agentId: string): Promise<string> {
48  const agent = (await $.agent.list()).find(known => known.id === agentId)
49  const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
50  return label(agent?.description ?? `agent ${agentId.slice(0, 4)}`, 'agent', isPresenting)
51}
52
53/** Checks one report and keeps the verdict. Report text is parsed and dropped. */
54async function review($: EngineInterface, agentId: string, text: string, isWindows: boolean): Promise<Report | undefined> {
55  const refs = extractRefs(text)
56  if (refs.files.length === 0 && refs.symbols.length === 0) return undefined
57
58  const seen = (await read($, activity))[agentId]
59  const roots = [...new Set([seen?.cwd, await $.session.root()].filter((root): root is string => root !== undefined && root !== ''))]
60  const verdict = await checkRefs(makeIo($), refs, roots, { isWindows, toolCalls: seen?.calls ?? 0 })
61
62  const report: Report = { agentId, label: await agentLabel($, agentId), at: await $.clock.now(), verdict }
63  await update($, reports, list => [...list, report].slice(-KEPT_REPORTS))
64  return report
65}
66
67/** Checks one report and writes the user's line. A report that cites nothing says nothing. */
68async function relay($: EngineInterface, agentId: string, text: string, isWindows: boolean): Promise<void> {
69  const report = await review($, agentId, text, isWindows)
70  const line = report && summaryLine(report.label, report.verdict)
71  if (line && (await read($, mode)) === 'notify') $.ui.log(line)
72}
73
74export const register: Register = on => {
75  let isWindows = false
76
77  on('session.start', async ($, e, next) => {
78    isWindows = (await $.env.get('OS')) === 'Windows_NT'
79    const stored = parseMode(String(await $.store.get('mode')))
80    await update($, mode, () => stored ?? 'notify')
81    await $.command.register({
82      name: 'spotcheck',
83      description: 'Show the references in subagent reports and whether they resolve, or set the mode',
84      argumentHint: 'notify|quiet|off',
85      immediate: true,
86    })
87    return next(e)
88  })
89
90  on('command.run', { command: 'spotcheck' }, async ($, e) => {
91    const argument = e.args.trim()
92    if (argument === '') {
93      await $.ui.open({ id: PANE, title: 'spotcheck' })
94      return { text: 'spotcheck pane opened.' }
95    }
96
97    const chosen = parseMode(argument)
98    if (!chosen) return { text: `spotcheck: "${argument}" is not a mode. Use notify, quiet or off.` }
99
100    await $.store.set('mode', chosen)
101    await update($, mode, () => chosen)
102    return { text: `spotcheck: mode is now ${chosen}.` }
103  })
104
105  on('classic.PostToolUse', async ($, e, next) => {
106    if (e.agent_id === undefined) return next(e)
107    const id = e.agent_id
108
109    // In auto mode a subagent reports through SubagentHandback, and its last answer is then empty. The
110    // call is the report, not work the agent did, so it is read here and not counted.
111    if (e.tool_name === HANDBACK_TOOL) {
112      const message = handbackMessage(e.tool_input)
113      if (message !== undefined && (await read($, mode)) !== 'off') await relay($, id, message, isWindows)
114      return next(e)
115    }
116
117    await update($, activity, seen => ({ ...seen, [id]: { cwd: e.cwd, calls: (seen[id]?.calls ?? 0) + 1 } }))
118    return next(e)
119  })
120
121  on('turn.complete', async ($, e, next) => {
122    if (e.agentId === undefined || (await read($, mode)) === 'off') return next(e)
123
124    await relay($, e.agentId, e.answer, isWindows)
125    await update($, activity, seen => Object.fromEntries(Object.entries(seen).filter(([id]) => id !== e.agentId)))
126    return next(e)
127  })
128
129  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
130    const ran = await next(e)
131    if (ran.deny !== undefined || ran.isError === true) return ran
132
133    const agentId = agentIdOf(ran.result)
134    if (agentId === undefined || (await read($, mode)) === 'off') return ran
135
136    const report = (await read($, reports)).find(known => known.agentId === agentId)
137    if (!report || !needsAttention(report.verdict)) return ran
138    return { ...ran, context: [...(ran.context ?? []), claudeNote(report.label, report.verdict)] }
139  })
140
141  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
142    const { Box, Button, Text } = $.ui.resolve(e)
143    const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
144    const list = await read($, reports)
145    const opened = await read($, openId)
146
147    return (
148      <Box flexDirection="column">
149        <Text>Mode: {await read($, mode)}</Text>
150        {list.length === 0 && <Text dimColor>No subagent report with references yet.</Text>}
151        {[...list].reverse().map(report => (
152          <Box key={`report-${report.agentId}`} flexDirection="column">
153            <Box>
154              <Text>
155                {report.label}: {outcome(report.verdict) || 'nothing could be checked'}{' '}
156              </Text>
157              <Button
158                key={`detail-${report.agentId}`}
159                label={opened === report.agentId ? 'Hide' : 'Details'}
160                onPress={() => update($, openId, current => (current === report.agentId ? '' : report.agentId))}
161              />
162            </Box>
163            {opened === report.agentId &&
164              report.verdict.files.map(file => <Text dimColor>{fileRow(file, isPresenting)}</Text>)}
165            {opened === report.agentId &&
166              report.verdict.symbols.map(symbol => (
167                <Text dimColor>
168                  {symbol.status.padEnd(8)} {isPresenting ? 'symbol' : `\`${symbol.name}\``}
169                </Text>
170              ))}
171          </Box>
172        ))}
173      </Box>
174    )
175  })
176}
177
hooks/check.ts 132 lines
1import type { FileRef, FileResult, SymbolResult, Verdict } from '../types'
2import type { Refs } from './parse'
3
4/** What the checks may do to the machine, as plain functions: the hooks module supplies them. */
5export type Io = {
6  exists: (path: string) => Promise<boolean>
7  size: (path: string) => Promise<number>
8  read: (path: string) => Promise<string>
9  /** `git grep -F` for a symbol under `cwd`. */
10  grep: (symbol: string, cwd: string) => Promise<'found' | 'missing' | 'unchecked'>
11  /** Absolute paths of the tracked files under `cwd` whose path ends with `path`. */
12  findByName: (path: string, cwd: string) => Promise<string[]>
13}
14
15/** Files larger than this are not read to count lines. */
16const MAX_READ_BYTES = 4 * 1024 * 1024
17
18const DRIVE = /^[A-Za-z]:[\\/]/
19const MSYS_DRIVE = /^\/([A-Za-z])\/(.*)$/
20
21const isAbsolute = (path: string): boolean => DRIVE.test(path) || path.startsWith('/') || path.startsWith('\\\\')
22
23/** A path as the file API takes it: Git Bash's `/d/x` becomes `D:/x` on Windows. */
24export function normalizePath(path: string, isWindows: boolean): string {
25  const msys = isWindows ? MSYS_DRIVE.exec(path) : null
26  return msys ? `${(msys[1] as string).toUpperCase()}:/${msys[2]}` : path
27}
28
29const joinPath = (root: string, path: string): string =>
30  `${root.replace(/[\\/]+$/, '')}/${path.replace(/^\.[\\/]/, '')}`
31
32/** Where a cited path could be: as given if absolute, else under each root in order. */
33export function candidates(path: string, roots: readonly string[], isWindows: boolean): string[] {
34  const normalized = normalizePath(path, isWindows)
35  if (path.startsWith('~')) return []
36  return isAbsolute(normalized) ? [normalized] : roots.map(root => joinPath(root, normalized))
37}
38
39/** Lines in a text, counting a final line without a newline; CRLF counts once. */
40export function countLines(text: string): number {
41  if (text === '') return 0
42  const breaks = text.split('\n').length - 1
43  return text.endsWith('\n') ? breaks : breaks + 1
44}
45
46const hasSeparator = (path: string): boolean => /[\\/]/.test(path)
47
48async function firstExisting(io: Io, paths: readonly string[]): Promise<number> {
49  for (const [index, path] of paths.entries()) {
50    if (await io.exists(path)) return index
51  }
52  return -1
53}
54
55type Located = { path: string; isMainTree: boolean; isByName: boolean }
56
57/**
58 * Where the cited file is: at the path as written, else, for a relative path,
59 * at the one tracked file whose path ends with it (agents often cite a path
60 * relative to a subfolder). `'ambiguous'` means several files match.
61 */
62async function locate(io: Io, ref: FileRef, roots: readonly string[], isWindows: boolean): Promise<Located | 'ambiguous' | undefined> {
63  const paths = candidates(ref.path, roots, isWindows)
64  const found = await firstExisting(io, paths)
65  if (found !== -1) return { path: paths[found] as string, isMainTree: roots.length > 1 && found > 0, isByName: false }
66
67  const isRelative = paths.length > 0 && !isAbsolute(normalizePath(ref.path, isWindows))
68  if (!isRelative) return undefined
69
70  for (const [index, root] of roots.entries()) {
71    const matches = await io.findByName(ref.path, root)
72    if (matches.length > 1) return 'ambiguous'
73    if (matches[0] !== undefined) return { path: matches[0], isMainTree: index > 0, isByName: true }
74  }
75  return undefined
76}
77
78async function checkFile(io: Io, ref: FileRef, roots: readonly string[], isWindows: boolean): Promise<FileResult> {
79  const located = await locate(io, ref, roots, isWindows)
80  if (located === 'ambiguous') return { ref, status: 'unchecked' }
81
82  if (located === undefined) {
83    // A bare name with no line may be a mention of any file anywhere; only a path or a line is a claim.
84    const isClaim = hasSeparator(ref.path) || ref.line !== undefined
85    return { ref, status: isClaim && candidates(ref.path, roots, isWindows).length > 0 ? 'missing' : 'unchecked' }
86  }
87
88  const { path, ...where } = located
89  if (ref.line === undefined) return { ref, status: 'ok', ...where }
90  if ((await io.size(path)) > MAX_READ_BYTES) return { ref, status: 'unchecked', ...where }
91
92  const lines = countLines(await io.read(path))
93  const cited = ref.endLine ?? ref.line
94  return { ref, status: cited > lines ? 'past-end' : 'ok', lines, ...where }
95}
96
97/** Resolves every reference against the agent's tree first, then the main tree. */
98export async function checkRefs(
99  io: Io,
100  refs: Refs,
101  roots: readonly string[],
102  options: { isWindows: boolean; toolCalls: number },
103): Promise<Verdict> {
104  const files = await Promise.all(refs.files.map(ref => checkFile(io, ref, roots, options.isWindows)))
105
106  const cwd = roots[0]
107  const symbols = await Promise.all(
108    refs.symbols.map(async (name): Promise<SymbolResult> => ({
109      name,
110      status: cwd === undefined ? 'unchecked' : await io.grep(name, cwd),
111    })),
112  )
113
114  return { files, symbols, isUnbacked: options.toolCalls === 0 && refs.files.length > 0 }
115}
116
117export type Problem =
118  | { kind: 'missing'; ref: FileRef }
119  | { kind: 'past-end'; ref: FileRef; lines: number }
120  | { kind: 'symbol'; name: string }
121
122/** What the verdict calls wrong, in the order the report cited it. */
123export function problems(verdict: Verdict): Problem[] {
124  const files = verdict.files.flatMap((file): Problem[] => {
125    if (file.status === 'missing') return [{ kind: 'missing', ref: file.ref }]
126    if (file.status === 'past-end') return [{ kind: 'past-end', ref: file.ref, lines: file.lines ?? 0 }]
127    return []
128  })
129  const symbols = verdict.symbols.filter(symbol => symbol.status === 'missing').map((symbol): Problem => ({ kind: 'symbol', name: symbol.name }))
130  return [...files, ...symbols]
131}
132
hooks/parse.ts 102 lines
1// Finds the file and symbol references in a subagent's report. It is
2// deliberately conservative: a miss costs nothing, a false "doesn't resolve"
3// costs the user's trust.
4
5import type { FileRef } from '../types'
6
7export type Refs = {
8  files: FileRef[]
9  symbols: string[]
10}
11
12export const MAX_FILES = 40
13export const MAX_SYMBOLS = 10
14const MIN_SYMBOL_LENGTH = 4
15
16const EXTENSIONS = new Set(
17  'ts tsx js jsx mjs cjs mts cts cs csproj sln py go rs java kt kts rb php c h cpp hpp cc swift scala sh bash ps1 psm1 bat cmd sql json jsonc yml yaml toml ini cfg conf md mdx txt css scss sass less html htm vue svelte xml xaml razor cshtml proto graphql lock gradle tf bicep dockerfile'.split(
18    ' ',
19  ),
20)
21
22/** Product names that look like files: `Node.js`, `Next.js`. */
23const PRODUCT_NAMES = new Set(['node', 'next', 'vue', 'nuxt', 'three', 'express', 'nest', 'react', 'angular', 'chart', 'd3', 'p5'])
24
25const KEYWORDS = new Set(['true', 'false', 'null', 'undefined', 'void', 'this', 'self', 'none', 'class', 'const', 'function', 'async', 'await', 'return', 'import', 'export', 'string', 'number', 'boolean', 'object', 'array'])
26
27const URL = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi
28const SEGMENT = String.raw`[\w@.+-]+`
29const FILE = new RegExp(
30  String.raw`(?<![\w/\\.:@-])` +
31    String.raw`((?:[A-Za-z]:[\\/]|\\\\|/|\.{1,2}[\\/]|~[\\/])?(?:${SEGMENT}[\\/])*${SEGMENT}\.[A-Za-z][A-Za-z0-9]{0,7})` +
32    String.raw`(?::(\d+)(?:[-–](\d+))?(?::\d+)?|#L(\d+)(?:-L?(\d+))?)?`,
33  'g',
34)
35const BACKTICKED = /`([^`\n]+)`/g
36const CODE_LIKE = /^(?:[a-z]+(?:[A-Z][a-z0-9]*)+|[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+|[a-z0-9]+(?:_[a-z0-9]+)+)$/
37
38const extensionOf = (path: string): string => (path.split('.').at(-1) ?? '').toLowerCase()
39const hasSeparator = (path: string): boolean => /[\\/]/.test(path)
40
41/** A path that starts with a host name: `github.com/org/repo/file.ts`, a URL without its scheme. */
42const HOST_PREFIX = /^(?:[\w-]+\.)+(?:com|org|net|io|dev|app|ai|co|uk|de|gov|edu)\//i
43
44function isFileLike(path: string): boolean {
45  if (!EXTENSIONS.has(extensionOf(path)) || HOST_PREFIX.test(path)) return false
46  if (hasSeparator(path)) return true
47
48  const [stem = ''] = path.split('.')
49  return !PRODUCT_NAMES.has(stem.toLowerCase())
50}
51
52const positive = (value: string | undefined): number | undefined => {
53  const number = Number(value)
54  return Number.isInteger(number) && number > 0 ? number : undefined
55}
56
57/** File references: `path`, `path:12`, `path:12-30`, `path:12:5`, `path#L12`, `path#L12-L30`. */
58export function extractFiles(report: string): FileRef[] {
59  const text = report.replace(URL, ' ')
60  const seen = new Set<string>()
61  const files: FileRef[] = []
62
63  for (const match of text.matchAll(FILE)) {
64    const [, path = '', colonStart, colonEnd, hashStart, hashEnd] = match
65    if (!isFileLike(path)) continue
66
67    const line = positive(colonStart ?? hashStart)
68    const endLine = positive(colonEnd ?? hashEnd)
69    const key = `${path}:${line ?? ''}-${endLine ?? ''}`
70    if (seen.has(key)) continue
71
72    seen.add(key)
73    files.push({ path, ...(line === undefined ? {} : { line }), ...(endLine === undefined ? {} : { endLine }) })
74    if (files.length === MAX_FILES) break
75  }
76
77  return files
78}
79
80/** Backticked identifiers that look like code: `refreshGrant(`, `RefreshGrant`, `refresh_grant`. */
81export function extractSymbols(report: string): string[] {
82  const names = new Set<string>()
83
84  for (const match of report.matchAll(BACKTICKED)) {
85    const name = (match[1] ?? '').trim().replace(/\(\)?$/, '')
86    const isCall = /\($|\(\)$/.test((match[1] ?? '').trim())
87    if (name.length < MIN_SYMBOL_LENGTH || KEYWORDS.has(name.toLowerCase())) continue
88    if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue
89    if (!isCall && !CODE_LIKE.test(name)) continue
90
91    names.add(name)
92    if (names.size === MAX_SYMBOLS) break
93  }
94
95  return [...names]
96}
97
98export const extractRefs = (report: string): Refs => ({
99  files: extractFiles(report),
100  symbols: extractSymbols(report),
101})
102
hooks/privacy.ts 28 lines
1// What may reach the screen. Descriptions are Claude's own words about a
2// command and can carry a client name, a path or a token, so every one is cut
3// and scrubbed, and `GROUND_RULES_PRESENTATION=1` replaces them with the kind.
4
5const LABEL_LIMIT = 28
6
7/** Long runs of token-like characters and URL credentials become an ellipsis. */
8export function scrub(text: string): string {
9  return text
10    .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]*@/gi, '')
11    .replace(/[A-Za-z0-9_\-+/=]{24,}/g, '…')
12    .replace(/\b[A-Za-z]:[\\/][^\s"']*/g, '…')
13    .replace(/(^|\s)\/[^\s"']+/g, '$1…')
14    .replace(/\s+/g, ' ')
15    .trim()
16}
17
18/** A task description as it may appear on screen. */
19export function label(description: string, kind: string, isPresenting: boolean): string {
20  if (isPresenting) return kind
21  const clean = scrub(description)
22  if (clean === '') return kind
23  return clean.length > LABEL_LIMIT ? `${clean.slice(0, LABEL_LIMIT - 1).trimEnd()}…` : clean
24}
25
26/** True when the presentation switch is set to the literal `1`. */
27export const isPresentation = (value: string | undefined): boolean => value === '1'
28
hooks/report.ts 94 lines
1import type { FileRef, FileResult, Verdict } from '../types'
2import { problems, type Problem } from './check'
3
4const NOTE_LIMIT = 5
5
6/** The id of the agent an Agent call ran, from a result of unknown shape. */
7export function agentIdOf(result: unknown): string | undefined {
8  if (typeof result !== 'object' || result === null || !('agentId' in result)) return undefined
9  return typeof result.agentId === 'string' ? result.agentId : undefined
10}
11
12/** The tool a subagent reports through when auto mode is on. */
13export const HANDBACK_TOOL = 'SubagentHandback'
14
15/** The report a SubagentHandback call carries, from an input of unknown shape. */
16export function handbackMessage(input: unknown): string | undefined {
17  if (typeof input !== 'object' || input === null || !('message' in input)) return undefined
18  return typeof input.message === 'string' ? input.message : undefined
19}
20
21const cited = (ref: FileRef): string =>
22  ref.line === undefined ? ref.path : `${ref.path}:${ref.line}${ref.endLine === undefined ? '' : `-${ref.endLine}`}`
23
24function describe(problem: Problem): string {
25  switch (problem.kind) {
26    case 'missing':
27      return `${cited(problem.ref)} does not exist`
28    case 'past-end':
29      return `${cited(problem.ref)} is past the end of the file (${problem.lines} lines)`
30    case 'symbol':
31      return `\`${problem.name}\` has no match`
32    default: {
33      const unreachable: never = problem
34      return unreachable
35    }
36  }
37}
38
39/** References that could be checked: the others are not counted either way. */
40const checked = (verdict: Verdict): number =>
41  verdict.files.filter(file => file.status !== 'unchecked').length +
42  verdict.symbols.filter(symbol => symbol.status !== 'unchecked').length
43
44/** The outcome of one report: `14/14`, or `11 refs, 2 don't resolve`; empty when nothing could be checked. */
45export function outcome(verdict: Verdict): string {
46  const wrong = problems(verdict).length
47  const total = checked(verdict)
48  const unbacked = verdict.isUnbacked ? 'cites files after 0 tool calls' : ''
49  const counts = total === 0 ? '' : wrong === 0 ? `${total}/${total}` : `${total} refs, ${wrong} don't resolve`
50  return [counts, unbacked].filter(part => part !== '').join(', ')
51}
52
53/** The line the user sees when an agent finishes, or undefined when there is nothing to say. */
54export function summaryLine(label: string, verdict: Verdict): string | undefined {
55  const text = outcome(verdict)
56  return text === '' ? undefined : `spotcheck · ${label}: ${text} · /spotcheck`
57}
58
59/** True when the report has something Claude should check before relaying it. */
60export const needsAttention = (verdict: Verdict): boolean => problems(verdict).length > 0 || verdict.isUnbacked
61
62/** The note Claude reads next to the Agent result. */
63export function claudeNote(label: string, verdict: Verdict): string {
64  const found = problems(verdict).map(describe)
65  const listed = found.slice(0, NOTE_LIMIT)
66  const more = found.length > NOTE_LIMIT ? `, and ${found.length - NOTE_LIMIT} more` : ''
67  const claims = listed.length > 1 ? `${listed.slice(0, -1).join(', ')} and ${listed.at(-1)}` : (listed[0] ?? '')
68
69  const sentences = [
70    found.length > 0 ? `${claims}${more}.` : '',
71    verdict.isUnbacked ? `${found.length > 0 ? 'It' : 'it'} made no tool calls before citing files.` : '',
72  ].filter(sentence => sentence !== '')
73  return `spotcheck: in ${label}'s report, ${sentences.join(' ')} Check ${found.length > 0 ? 'these' : 'its claims'} before relaying.`
74}
75
76/** One row of the pane for a file reference. */
77export function fileRow(file: FileResult, isPresenting: boolean): string {
78  const where = isPresenting ? 'file' : cited(file.ref)
79  switch (file.status) {
80    case 'ok':
81      return `ok       ${where}${file.isByName ? ' (matched by name)' : ''}${file.isMainTree ? ' (main tree)' : ''}`
82    case 'missing':
83      return `missing  ${where}`
84    case 'past-end':
85      return `past end ${where} (${file.lines} lines)`
86    case 'unchecked':
87      return `unchecked ${where}`
88    default: {
89      const unreachable: never = file.status
90      return unreachable
91    }
92  }
93}
94
types/index.d.ts 54 lines
1export type Mode = 'notify' | 'quiet' | 'off'
2
3export type FileRef = {
4  /** The path as the report wrote it, without the line suffix. */
5  path: string
6  /** First cited line, 1-based. */
7  line?: number
8  /** Last cited line of a range. */
9  endLine?: number
10}
11
12export type FileResult = {
13  ref: FileRef
14  status: 'ok' | 'missing' | 'past-end' | 'unchecked'
15  /** Line count of the file, when it was read. */
16  lines?: number
17  /** Found under the main tree, not the agent's own worktree. */
18  isMainTree?: boolean
19  /** The path as written didn't exist; one tracked file ends with it. */
20  isByName?: boolean
21}
22
23export type SymbolResult = { name: string; status: 'found' | 'missing' | 'unchecked' }
24
25export type Verdict = {
26  files: FileResult[]
27  symbols: SymbolResult[]
28  /** The report cites files but the agent made no tool call. */
29  isUnbacked: boolean
30}
31
32/** One checked report. The report text itself is never kept. */
33export type Report = {
34  agentId: string
35  label: string
36  at: number
37  verdict: Verdict
38}
39
40/** What an agent has done so far: its tree and how many tools it called. */
41export type Activity = Record<string, { cwd: string; calls: number }>
42
43declare module 'claude-code' {
44  interface PluginState {
45    spotcheck: {
46      mode: Mode
47      reports: Report[]
48      activity: Activity
49      /** The report whose references the pane shows in full. */
50      openId: string
51    }
52  }
53}
54