Toasts when a Bash verification probe is misleading: $? read after a pipeline, grep used as proof of absence, git grep ignoring untracked files

Toasts when a Bash verification probe is misleading, before it can fool you.
Bash command of the main conversation (subagents are ignored). The command always runs untouched: probe-check never denies or rewrites.$? read right after a pipeline (cmd | tail; echo $?, echo "exit=$?") reports the last stage, not cmd. Not flagged when -o pipefail appears earlier in the same command or PIPESTATUS is used.x=$(a | b); echo $?, same problem.git grep (also git -C <dir> grep, after rtk or env prefixes) that printed nothing while git status --porcelain --untracked-files=all (limited to the pathspecs after --, run in the -C directory or the session's, 5 s timeout) lists untracked files: they were not searched. Checked after the command ran; silent when git grep matched, nothing is untracked, or --untracked / --no-index is used.grep … file || echo "not found" with no existence check of the file: a missing file also triggers the message.$? right after a simple command, or after cmd > /dev/null 2>&1, is fine.| Command | Effect |
|---|---|
/probe-check or /probe-check list | Show the last 10 findings, newest first. |
/probe-check off | Stop checking. |
/probe-check on | Resume. |
claude --plugin-dir /path/to/ModsTools/mods/probe-check
-- it checks the whole repository. If git status fails or times out, nothing is reported.$? read in a later pipeline segment or inside a function.$?.grep (at least a pattern and a path) and a not found-style echo.claude plugin validate mods/probe-check
claude plugin test mods/probe-check # 25 testshooks/register.ts 86 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { classify, countUntracked, gitGrepScope, untrackedFinding } from './probes'
4import type { Finding } from './probes'
5
6const KEEP = 10
7
8type Seen = Finding & { command: string }
9
10const short = (command: string) => {
11 const line = command.replace(/\s+/g, ' ').trim()
12
13 return line.length > 80 ? `${line.slice(0, 79)}…` : line
14}
15
16// After a `git grep` that matched nothing: untracked files in its path were never searched.
17async function untrackedAfterGitGrep($: EngineInterface, command: string, result: unknown): Promise<Finding | null> {
18 const scope = gitGrepScope(command)
19 const stdout = typeof result === 'object' && result !== null ? (result as { stdout?: unknown }).stdout : undefined
20 if (scope === null || (typeof stdout === 'string' && stdout.trim() !== '')) return null
21 try {
22 const argv = ['git', 'status', '--porcelain', '--untracked-files=all', ...(scope.pathspecs.length > 0 ? ['--', ...scope.pathspecs] : [])]
23 const status = await $.process.run(argv, { ...(scope.dir === null ? {} : { cwd: scope.dir }), timeoutMs: 5000 })
24 const count = status.exitCode === 0 ? countUntracked(status.stdout) : 0
25
26 return count > 0 ? untrackedFinding(count) : null
27 } catch {
28 return null
29 }
30}
31
32export const register: Register = on => {
33 let isEnabled = true
34 let turnIds = new Set<string>()
35 const recent: Seen[] = []
36
37 on('session.start', async ($, e, next) => {
38 await $.command.register({
39 name: 'probe-check',
40 description: 'Misleading verification probes (list | off | on)',
41 })
42
43 return next(e)
44 })
45
46 on('command.run', { command: 'probe-check' }, ($, e) => {
47 const arg = e.args.trim()
48 if (arg === 'off' || arg === 'on') {
49 isEnabled = arg === 'on'
50 return { text: `probe-check is ${arg}.` }
51 }
52 if (recent.length === 0) return { text: `probe-check is ${isEnabled ? 'on' : 'off'}. No misleading probe found yet.` }
53
54 return { text: [`probe-check is ${isEnabled ? 'on' : 'off'}. Last ${recent.length} finding(s), newest first:`, ...recent.map(f => `- [${f.id}] ${f.message} (${f.command})`)].join('\n') }
55 })
56
57 on('prompt.submit', ($, e, next) => {
58 turnIds = new Set()
59
60 return next(e)
61 })
62
63 // Records findings and returns those not yet shown this turn.
64 const fresh = (findings: Finding[], command: string): Finding[] =>
65 findings.filter(finding => {
66 recent.unshift({ ...finding, command: short(command) })
67 recent.length = Math.min(recent.length, KEEP)
68 if (turnIds.has(finding.id)) return false
69 turnIds.add(finding.id)
70
71 return true
72 })
73
74 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75 const isActive = isEnabled && e.agentId === undefined
76 if (isActive) for (const finding of fresh(classify(e.command), e.command)) $.ui.toast(`probe-check: ${finding.message}`)
77 const ran = await next(e)
78 if (isActive && ran.deny === undefined) {
79 const found = await untrackedAfterGitGrep($, e.command, ran.result)
80 if (found !== null) for (const finding of fresh([found], e.command)) $.ui.toast(`probe-check: ${finding.message}`)
81 }
82
83 return ran
84 })
85}
86hooks/probes.ts 141 lines1// Pure logic: spot Bash verification probes that can mislead.
2
3export type Finding = { id: string; message: string }
4
5type Segment = { raw: string; masked: string; sep: string; hasPipe: boolean; pipeInside: boolean }
6
7// Split a command on top-level && || ; and newlines. `masked` hides quoted text (except $? in double quotes).
8export function splitSegments(command: string): Segment[] {
9 const segments: Segment[] = []
10 let quote: string | null = null
11 let depth = 0
12 let start = 0
13 let sep = ''
14 let masked = ''
15 let hasPipe = false
16 let pipeInside = false
17 const close = (end: number, next: string) => {
18 segments.push({ raw: command.slice(start, end), masked, sep, hasPipe, pipeInside })
19 start = end + next.length
20 sep = next
21 masked = ''
22 hasPipe = false
23 pipeInside = false
24 }
25 for (let i = 0; i < command.length; i++) {
26 const c = command[i] ?? ''
27 const two = command.slice(i, i + 2)
28 if (quote === "'") {
29 masked += c === "'" ? c : 'x'
30 if (c === "'") quote = null
31 } else if (quote === '"') {
32 if (c === '"') {
33 quote = null
34 masked += c
35 } else if (c === '\\') {
36 masked += 'xx'
37 i++
38 } else masked += '|;&()'.includes(c) ? 'x' : c
39 } else if (c === '\\') {
40 masked += 'xx'
41 i++
42 } else if (c === "'" || c === '"') {
43 quote = c
44 masked += c
45 } else if (c === '(') {
46 depth++
47 masked += c
48 } else if (c === ')') {
49 depth = Math.max(0, depth - 1)
50 masked += c
51 } else if (depth === 0 && (two === '&&' || two === '||')) {
52 close(i, two)
53 i++
54 } else if (depth === 0 && (c === ';' || c === '\n')) close(i, c)
55 else {
56 if (c === '|') {
57 if (depth === 0) hasPipe = true
58 else pipeInside = true
59 }
60 masked += c
61 }
62 }
63 close(command.length, '')
64
65 return segments
66}
67
68const PIPEFAIL = /-\w*o\s+pipefail\b/
69const GIT_GREP = /^\s*(?:rtk\s+)?(?:sudo\s+)?(?:\w+=\S*\s+)*git\s+(?:(?:-C\s+\S+|--no-pager)\s+)*grep\b/
70const GREP = /^\s*(?:rtk\s+)?grep\b/
71const NOT_FOUND = /not found|no match|absent|missing|none|not present|nothing/i
72const EXISTS_CHECK = /\[\[?\s+!?\s*-[efdrs]\s|\btest\s+!?\s*-[efdrs]\s|\b(?:ls|stat)\s/
73const ASSIGN_SUBST = /^\s*(?:(?:export|local|declare|readonly)\s+)?\w+=\$\(/
74
75const MESSAGES = {
76 'pipe-status': '$? after a pipeline is the last stage\'s status, not the command\'s. Use set -o pipefail or ${PIPESTATUS[0]}.',
77 'assign-pipe-status': '$? after x=$(a | b) is the last stage\'s status, not a\'s. Use set -o pipefail or capture a on its own.',
78 'grep-absent': 'grep || echo "not found" also fires when the file is missing. Check the path exists first.',
79}
80
81function reportsAbsence(segments: Segment[], i: number): boolean {
82 const next = segments[i + 1]
83 if (next === undefined) return false
84 const candidate = next.sep === '||' ? next : next.sep === '&&' && segments[i + 2]?.sep === '||' ? segments[i + 2] : undefined
85
86 return candidate !== undefined && /^\s*echo\b/.test(candidate.raw) && NOT_FOUND.test(candidate.raw)
87}
88
89function operandCount(masked: string): number {
90 return masked.trim().split(/\s+/).slice(1).filter(token => !token.startsWith('-') && token !== '').length
91}
92
93// The misleading probes found in a Bash command, one finding per distinct pattern.
94export function classify(command: string): Finding[] {
95 const segments = splitSegments(command)
96 const ids = new Set<keyof typeof MESSAGES>()
97 const usesPipestatus = command.includes('PIPESTATUS')
98 const hasExistsCheck = EXISTS_CHECK.test(command)
99 let pipefail = false
100 segments.forEach((segment, i) => {
101 if (PIPEFAIL.test(segment.raw)) pipefail = true
102 const prev = segments[i - 1]
103 if (prev !== undefined && !pipefail && !usesPipestatus && segment.masked.includes('$?')) {
104 if (prev.hasPipe) ids.add('pipe-status')
105 else if (prev.pipeInside && ASSIGN_SUBST.test(prev.raw)) ids.add('assign-pipe-status')
106 }
107 if (GREP.test(segment.raw) && !segment.hasPipe && operandCount(segment.masked) >= 2 && !hasExistsCheck && reportsAbsence(segments, i)) ids.add('grep-absent')
108 })
109
110 return [...ids].map(id => ({ id, message: MESSAGES[id] }))
111}
112
113export type GitGrepScope = { dir: string | null; pathspecs: string[] }
114
115const tokenize = (text: string): string[] =>
116 [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
117
118// The directory (-C) and pathspecs (after --) of the first `git grep` in a command, or null when there is none
119// or it searches untracked files itself (--untracked, --no-index).
120export function gitGrepScope(command: string): GitGrepScope | null {
121 const segment = splitSegments(command).find(candidate => GIT_GREP.test(candidate.raw))
122 if (segment === undefined) return null
123 const all = tokenize(segment.raw)
124 const pipe = all.indexOf('|')
125 const tokens = all.slice(all.indexOf('git'), pipe === -1 ? undefined : pipe).filter(token => !/^\d*>/.test(token))
126 const grep = tokens.indexOf('grep')
127 if (tokens.slice(grep).some(token => token === '--untracked' || token === '--no-index')) return null
128 const c = tokens.indexOf('-C')
129 const dash = tokens.indexOf('--', grep)
130
131 return { dir: c !== -1 && c < grep ? (tokens[c + 1] ?? null) : null, pathspecs: dash === -1 ? [] : tokens.slice(dash + 1) }
132}
133
134// How many untracked files `git status --porcelain` lists.
135export const countUntracked = (stdout: string): number => stdout.split('\n').filter(line => line.startsWith('?? ')).length
136
137export const untrackedFinding = (count: number): Finding => ({
138 id: 'git-grep-untracked',
139 message: `git grep found nothing, but ${count} untracked file(s) in that path were not searched — use grep -r`,
140})
141