SLOPSHOPPER

build-gate

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

newbandguardcommandprompt
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/build-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · build-gate
› fix the failing auth test and add an audit log call ⏺ 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 › /build-gate ⎿ build-gate: build-gate is on. Last turn: changed: TS/JS; no matching build: TS/JS. ⚙ build-gate: TS/JS changed, no build run this turn [ Dismiss ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚙ build-gate: TS/JS changed, no build run this turn [ Dismiss ]
README

build-gate

A band above the prompt when a turn changed source code and never ran a matching build or type-check.

What it does

  • Tracks each main-conversation turn (subagents are ignored). It does not read the answer text.
  • Code changed = a successful 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.
  • Matching check = a 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.
  • When a turn ends with unchecked languages, the band shows ⚙ build-gate: Swift, Python changed, no build run this turn with a Dismiss button.
  • The band clears, language by language, on the next turn that runs a matching check, or on Dismiss.

Commands

CommandEffect
/build-gate offStop checking (and clear the band).
/build-gate onResume.
/build-gate statusShow the last turn: languages changed and still unchecked.

Install

claude --plugin-dir /path/to/ModsTools/mods/build-gate

Limits

  • It cannot tell whether the check covered the edited files, nor whether it passed.
  • Edits made through Bash (sed -i, redirects) are not seen.
  • A check run before the edit in the same turn does not count.
  • Kotlin/Java checks and cargo run go beyond the original list; adjust hooks/gate.ts if unwanted.
  • The on/off switch lasts for the session only.

Develop

claude plugin validate mods/build-gate
claude plugin test mods/build-gate   # 16 tests
Source 3 files
hooks/register.tsx 93 lines
1import { 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}
93
hooks/gate.ts 69 lines
1// 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`
69
types/index.d.ts 8 lines
1export type Language = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'build-gate': { missing: Language[] }
6  }
7}
8