Band above the prompt when a turn changed source code without running a matching build or type-check

A band above the prompt when a turn changed source code and never ran a matching build or type-check.
Edit, Write or NotebookEdit on .swift .ts .tsx .js .jsx .mjs .cjs .py .rs .go .kt .java .c .cc .cpp .m .mm .h, mapped to Swift, TS/JS, Python, Rust, Go, Kotlin/Java or C/ObjC.Bash command with a segment (split on &&, ;, ||, newlines), after the edit, that builds or checks that language: Swift xcodebuild, swift build|test; TS/JS tsc, npm|pnpm|yarn [run] build|test|lint|typecheck, vitest, jest, claude plugin test|validate; Python pytest, python -m pytest, mypy, ruff; Rust cargo build|test|check|clippy|run; Go go build|test|vet; C/ObjC xcodebuild, make, clang; Kotlin/Java gradle, ./gradlew, mvn. rtk (and its err|test wrapper), sudo, npx and VAR=value prefixes are stripped. A check that fails still counts.⚙ build-gate: Swift, Python changed, no build run this turn with a Dismiss button.| Command | Effect |
|---|---|
/build-gate off | Stop checking (and clear the band). |
/build-gate on | Resume. |
/build-gate status | Show the last turn: languages changed and still unchecked. |
claude --plugin-dir /path/to/ModsTools/mods/build-gate
Bash (sed -i, redirects) are not seen.cargo run go beyond the original list; adjust hooks/gate.ts if unwanted.claude plugin validate mods/build-gate
claude plugin test mods/build-gate # 16 testshooks/register.tsx 93 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { bandText, checkLanguages, languageOf } from './gate'
5
6const missing = atom({ plugin: 'build-gate', key: 'missing' } as const, [])
7
8export const register: Register = on => {
9 let isEnabled = true
10 let pending = new Set<string>()
11 let last: { changed: string[]; unverified: string[] } | null = null
12 let changedThisTurn = new Set<string>()
13 let ranThisTurn = new Set<string>()
14
15 on('session.start', async ($, e, next) => {
16 await $.command.register({
17 name: 'build-gate',
18 description: 'Band when code changed without a build (off | on | status)',
19 })
20
21 return next(e)
22 })
23
24 on('command.run', { command: 'build-gate' }, async ($, e) => {
25 const arg = e.args.trim()
26 if (arg === 'off') {
27 isEnabled = false
28 await update($, missing, () => [])
29 return { text: 'build-gate is off.' }
30 }
31 if (arg === 'on') {
32 isEnabled = true
33 return { text: 'build-gate is on.' }
34 }
35 const state = isEnabled ? 'on' : 'off'
36 if (last === null) return { text: `build-gate is ${state}. No turn completed yet.` }
37 const list = (items: string[]) => (items.length === 0 ? 'none' : items.join(', '))
38
39 return { text: `build-gate is ${state}. Last turn: changed: ${list(last.changed)}; no matching build: ${list(last.unverified)}.` }
40 })
41
42 on('prompt.submit', ($, e, next) => {
43 pending = new Set()
44 changedThisTurn = new Set()
45 ranThisTurn = new Set()
46
47 return next(e)
48 })
49
50 on('tool.call', async ($, e, next) => {
51 const ran = await next(e)
52 if (e.agentId !== undefined || ran.deny !== undefined) return ran
53 if (e.tool === 'Bash') {
54 for (const language of checkLanguages(e.command)) {
55 pending.delete(language)
56 ranThisTurn.add(language)
57 }
58 return ran
59 }
60 if (ran.isError === true) return ran
61 const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
62 const language = path === undefined ? null : languageOf(path)
63 if (language !== null) {
64 pending.add(language)
65 changedThisTurn.add(language)
66 }
67
68 return ran
69 })
70
71 on('turn.complete', async ($, e, next) => {
72 if (e.agentId !== undefined || e.isAborted || !isEnabled) return next(e)
73 const unverified = [...pending]
74 last = { changed: [...changedThisTurn], unverified }
75 await update($, missing, previous => [...new Set([...previous.filter(language => !ranThisTurn.has(language)), ...unverified])])
76
77 return next(e)
78 })
79
80 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81 const languages = await read($, missing)
82 if (languages.length === 0 || e.props.hasSurvey) return next(e)
83 const { Box, Button, Text } = $.ui.resolve(e)
84
85 return (
86 <Box>
87 <Text color="yellow">{bandText(languages)} </Text>
88 <Button key="dismiss" label="Dismiss" onPress={() => update($, missing, () => [])} />
89 </Box>
90 )
91 })
92}
93hooks/gate.ts 69 lines1// Pure logic: which language a path belongs to, and which languages a Bash command builds or type-checks.
2
3const EXTENSIONS: Record<string, string> = {
4 swift: 'Swift',
5 ts: 'TS/JS',
6 tsx: 'TS/JS',
7 js: 'TS/JS',
8 jsx: 'TS/JS',
9 mjs: 'TS/JS',
10 cjs: 'TS/JS',
11 py: 'Python',
12 rs: 'Rust',
13 go: 'Go',
14 kt: 'Kotlin/Java',
15 java: 'Kotlin/Java',
16 c: 'C/ObjC',
17 cc: 'C/ObjC',
18 cpp: 'C/ObjC',
19 m: 'C/ObjC',
20 mm: 'C/ObjC',
21 h: 'C/ObjC',
22}
23
24// The language of a source path, or null when it is not source code.
25export function languageOf(path: string): string | null {
26 const match = /\.([A-Za-z]+)$/.exec(path)
27
28 const extension = match?.[1]
29
30 return extension !== undefined && Object.hasOwn(EXTENSIONS, extension) ? (EXTENSIONS[extension] ?? null) : null
31}
32
33const WRAPPERS = /^(?:sudo|npx|bunx|time|env|nohup)\s+/
34const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/
35const RTK_SUBCOMMAND = /^(?:err|test|proxy|summary)\s+(?=\S)/
36const CHECKS: [RegExp, string[]][] = [
37 [/^xcodebuild\b/, ['Swift', 'C/ObjC']],
38 [/^swift\s+(?:build|test)\b/, ['Swift']],
39 [/^(?:tsc|vitest|jest)\b/, ['TS/JS']],
40 [/^(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:build|test|lint|typecheck)\b/, ['TS/JS']],
41 [/^claude\s+plugin\s+(?:test|validate)\b/, ['TS/JS']],
42 [/^(?:pytest|mypy|ruff)\b/, ['Python']],
43 [/^python3?\s+-m\s+pytest\b/, ['Python']],
44 [/^cargo\s+(?:build|test|check|clippy|run)\b/, ['Rust']],
45 [/^go\s+(?:build|test|vet)\b/, ['Go']],
46 [/^(?:make|clang(?:\+\+)?)\b/, ['C/ObjC']],
47 [/^(?:gradle|\.\/gradlew|mvn)\b/, ['Kotlin/Java']],
48]
49
50function segmentLanguages(segment: string): string[] {
51 let rest = segment.trim().replace(/^[({]\s*/, '')
52 for (;;) {
53 let stripped = rest.replace(WRAPPERS, '').replace(ENV_ASSIGN, '')
54 if (/^rtk\s+/.test(stripped)) stripped = stripped.replace(/^rtk\s+/, '').replace(RTK_SUBCOMMAND, '')
55 if (stripped === rest) break
56 rest = stripped
57 }
58
59 return CHECKS.filter(([pattern]) => pattern.test(rest)).flatMap(([, languages]) => languages)
60}
61
62// The languages covered by the build, test or type-check commands in a shell command.
63export function checkLanguages(command: string): string[] {
64 return [...new Set(command.split(/&&|\|\||;|\n/).flatMap(segmentLanguages))]
65}
66
67// Band text for the languages still unverified.
68export const bandText = (languages: string[]): string => `⚙ build-gate: ${languages.join(', ')} changed, no build run this turn`
69types/index.d.ts 8 lines1export type Language = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'build-gate': { missing: Language[] }
6 }
7}
8