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

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.
claude plugin install spotcheck@ground-rules
Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.
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
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.refreshGrant(, RefreshGrant, refresh_grant.How it checks
| Reference | Check | Result |
|---|---|---|
| A path | Exists under the agent's worktree, then under the session root. | ok, or missing |
| A path relative to a subfolder | git ls-files for the one tracked file whose path ends with it. | ok, or unchecked when several match |
| A line or range | The file's line count, CRLF-safe, for files up to 4 MiB. | ok, or past the end of the file |
| A symbol | git grep -F -n -I -e <symbol> --, five second timeout. | found, missing, or unchecked |
| A bare name with no line | Looked up like a path. If it is nowhere, it is not a claim. | unchecked |
| The report | The 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
notify mode.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.
/spotcheck notify|quiet|off, or the mode option. notify is the default.
| Hooks | session.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. |
| Reads | A finished subagent's final text, parsed and dropped. File existence, size and contents of the cited files. |
| Runs | git grep -F and git ls-files, in the agent's tree. |
| Stores | The verdicts of the last 20 reports: references and statuses, not text. |
| Never | A 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.
git grep can time out; those symbols show as unchecked.hooks/register.tsx 177 lines1import { 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}
177hooks/check.ts 132 lines1import 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}
132hooks/parse.ts 102 lines1// 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})
102hooks/privacy.ts 28 lines1// 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'
28hooks/report.ts 94 lines1import 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}
94types/index.d.ts 54 lines1export 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