Runs the fast CI checks (scripts/ci/preflight.sh) before a git push leaves the session; guards update-branch against failing or still-running checks; nudges…

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.ts 133 lines1import type { Register } from 'claude-code'
2
3import {
4 PREFLIGHT_TIMEOUT_MS, decidePush, decideUpdateBranch, hasSkip, nudgeFor, parseCheckRuns, parsePushes, remember,
5} from './logic'
6import type { Push, PreflightRun } from './logic'
7
8// Why this exists (LC-1846): CI runs ~19 checks per push on a CPU-bound VPS. On 2026-10-19 the
9// schema-drift check failed twice (#1833, #1834) and each failure cost a full round, although the
10// checks take seconds locally. scripts/ci/preflight.sh runs them before the push leaves the session.
11// All decisions live in ./logic.ts (unit-tested); this file only gathers facts and acts.
12
13const UPDATE_BRANCH = /^mcp__(github|GitHub_Custom)__update_pull_request_branch$/
14const STORE_KEY = 'lastPushByBranch'
15const SCRIPT = 'scripts/ci/preflight.sh'
16
17// Callout convention (claude-mods/README.md): prefix for the model, toast for the operator.
18function say($: any, text: string, toast?: string, isQuiet = false): string {
19 const msg = `[mod:ci-preflight] ${text}`
20 if (!isQuiet) $.ui.toast(toast ? `[mod:ci-preflight] ${toast}` : msg)
21 return msg
22}
23
24function textOf(r: { content?: readonly unknown[] }): string {
25 return (r.content ?? []).map(c => String((c as { text?: unknown }).text ?? '')).join('')
26}
27
28async function repoTop($: any, p: Push): Promise<string | undefined> {
29 try {
30 const r = await $.process.run(['git', '-C', p.dir ?? '.', 'rev-parse', '--show-toplevel'], { timeoutMs: 10_000 })
31 const top = String(r.stdout ?? '').trim()
32 return r.exitCode === 0 && top ? top : undefined
33 } catch { return undefined }
34}
35
36async function runPreflight($: any, p: Push, top: string | undefined): Promise<PreflightRun> {
37 if (p.isDirUnresolved) return { kind: 'unresolved-dir' }
38 // Other repos get this mod through the bbm-mods mirror; only a repo that carries the script is checked.
39 if (!top || !(await $.fs.exists(`${top}/${SCRIPT}`))) return { kind: 'not-applicable' }
40 const started = await $.clock.now()
41 try {
42 const r = await $.process.run(['bash', `${top}/${SCRIPT}`, top, 'origin/main'], { cwd: top, timeoutMs: PREFLIGHT_TIMEOUT_MS })
43 return { kind: 'exit', code: r.exitCode, output: `${r.stdout ?? ''}${r.stderr ? '\n' + r.stderr : ''}` }
44 } catch (x) {
45 const message = x instanceof Error ? x.message : String(x)
46 // process.run kills the child and rejects at timeoutMs. The rejection's wording is not a contract, so a
47 // rejection after (nearly) the whole budget counts as a timeout too. Never block on our own slowness.
48 const elapsed = (await $.clock.now()) - started
49 const isTimeout = /time/i.test(message) || elapsed >= PREFLIGHT_TIMEOUT_MS * 0.9
50 return isTimeout ? { kind: 'timeout', seconds: PREFLIGHT_TIMEOUT_MS / 1000 } : { kind: 'error', message: message.slice(0, 120) }
51 }
52}
53
54async function branchOf($: any, p: Push, top: string | undefined): Promise<string> {
55 if (p.branch) return p.branch
56 try {
57 const r = await $.process.run(['git', '-C', top ?? p.dir ?? '.', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 10_000 })
58 return r.exitCode === 0 ? String(r.stdout ?? '').trim() : ''
59 } catch { return '' }
60}
61
62export const register: Register = on => {
63 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64 const command = String((e as { command?: unknown }).command ?? '')
65 if (!command.includes('push')) return next(e)
66 const pushes = parsePushes(command)
67 if (pushes.length === 0) return next(e)
68
69 const isSkip = hasSkip(command)
70 const notes: string[] = []
71 const targets: { p: Push; top: string | undefined }[] = []
72 const seen = new Set<string>()
73 for (const p of pushes) {
74 const key = `${p.dir ?? ''}|${p.isDirUnresolved}`
75 if (seen.has(key)) continue
76 seen.add(key)
77 const top = await repoTop($, p)
78 targets.push({ p, top })
79 const run: PreflightRun = isSkip ? { kind: 'skipped' } : await runPreflight($, p, top)
80 const d = decidePush(run)
81 if (d.deny !== undefined) return { deny: say($, d.deny, d.toast) }
82 for (const c of d.context ?? []) notes.push(say($, c, undefined, d.isQuiet))
83 }
84
85 const ran = await next(e)
86 if (ran.deny !== undefined || ran.isError) return ran
87
88 // Batching nudge: warning only, after a push that looks successful.
89 try {
90 const now = await $.clock.now()
91 let map = (await $.store.get(STORE_KEY)) as Record<string, number> | undefined
92 for (const { p, top } of targets) {
93 const branch = await branchOf($, p, top)
94 if (!branch) continue
95 const k = `${top ?? p.dir ?? ''}::${branch}`
96 const nudge = nudgeFor(map?.[k], now)
97 if (nudge) notes.push(say($, nudge))
98 map = remember(map, k, now)
99 }
100 await $.store.set(STORE_KEY, map ?? {})
101 } catch { /* a store or clock failure must never change the push's outcome */ }
102
103 return notes.length ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
104 // Fail open: a bug in this mod must never stop a push.
105 }).catch(($, e, next) => next(e))
106
107 // Update-branch guard. ADVISORY-STRENGTH, FAILS OPEN: when the check runs cannot be read the update
108 // is allowed with a warning (see decideUpdateBranch). CLAUDE.md section 5 owns the rule.
109 on('tool.call', { tool: UPDATE_BRANCH }, async ($, ev, next) => {
110 const e = ev as unknown as Record<string, unknown>
111 let runs: ReturnType<typeof parseCheckRuns>
112 let readError: string | undefined
113 try {
114 const server = String(e.tool).split('__')[1] ?? 'github'
115 const r = await $.mcp.call(server, 'pull_request_read', {
116 method: 'get_check_runs', owner: e.owner, repo: e.repo, pullNumber: e.pullNumber, perPage: 100,
117 })
118 if (r.isError) readError = 'the MCP call returned an error'
119 else {
120 runs = parseCheckRuns(textOf(r))
121 if (runs === undefined) readError = 'unrecognised response shape'
122 }
123 } catch (x) {
124 readError = x instanceof Error ? x.message.slice(0, 120) : 'MCP call failed'
125 }
126 const d = decideUpdateBranch(runs, readError)
127 if (d.deny !== undefined) return { deny: say($, d.deny, d.toast) }
128 const ran = await next(ev)
129 if (ran.deny !== undefined || !d.context) return ran
130 return { ...ran, context: [...(ran.context ?? []), ...d.context.map(c => say($, c))] }
131 }).catch(($, e, next) => next(e))
132}
133hooks/logic.ts 293 lines1// Pure decision logic for ci-preflight (LC-1846). No `$`, no I/O: register.ts feeds it facts and
2// acts on the answer, so every rule here is unit-tested without an engine.
3
4export const NUDGE_WINDOW_MS = 10 * 60_000
5export const PREFLIGHT_TIMEOUT_MS = 180_000
6export const MAX_DENY_LINES = 40
7export const SKIP_RE = /(^|[\s;&|'"])PREFLIGHT_SKIP=1(?![A-Za-z0-9_])/
8
9// ---------------------------------------------------------------------------------------------
10// 1. Which commands are a push?
11//
12// WHAT WE CHOSE. The command is split into simple commands at && || ; | & and newlines, outside
13// quotes, and each is tokenised with quotes removed. A command is a push only when, after
14// leading VAR=val words and wrappers (env, command, exec, time, nohup), its program is `git`, its
15// git global options (-C dir, -c k=v, --git-dir ...) are skipped, and its subcommand is `push`.
16// So `echo "git push"` (program is echo), `grep 'git push' f` and a commit message that mentions a
17// push are NOT pushes. `bash -c 'git push'` / `sh -c` / `eval` is parsed recursively. Not seen
18// (the hook fails open on these): `$(...)`, backticks, aliases, a script that pushes.
19//
20// NOT a push for this mod: --dry-run / -n, --delete / -d, and the `git push origin :branch` delete
21// refspec. Nothing is sent that CI would run on.
22// ---------------------------------------------------------------------------------------------
23
24export type Push = {
25 /** Directory the push runs in: absolute, or relative to the session's cwd; undefined = the cwd. */
26 dir?: string
27 /** True when the directory could not be resolved statically (`~`, `$VAR`, `$(...)`). */
28 isDirUnresolved: boolean
29 /** Explicit branch (from a refspec) when the command names one. */
30 branch?: string
31}
32
33const WRAPPERS = new Set(['env', 'command', 'exec', 'time', 'nohup'])
34const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--exec-path'])
35const PUSH_VALUE_OPTS = new Set(['--repo', '-o', '--push-option', '--receive-pack', '--exec'])
36const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash'])
37
38/** Splits at unquoted && || ; | & and newlines, then into words with quotes removed. */
39export function splitCommands(command: string): string[][] {
40 const out: string[][] = []
41 let words: string[] = []
42 let cur = ''
43 let has = false
44 let quote: '' | "'" | '"' = ''
45 const endWord = () => { if (has) words.push(cur); cur = ''; has = false }
46 const endCmd = () => { endWord(); if (words.length) out.push(words); words = [] }
47 for (let i = 0; i < command.length; i++) {
48 const c = command[i]!
49 if (quote === "'") { if (c === "'") quote = ''; else cur += c; continue }
50 if (quote === '"') {
51 if (c === '"') quote = ''
52 else if (c === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1]!)) cur += command[++i]
53 else cur += c
54 continue
55 }
56 if (c === "'" || c === '"') { quote = c; has = true; continue }
57 if (c === '\\' && i + 1 < command.length) { cur += command[++i]; has = true; continue }
58 if (c === '\n' || c === ';' || c === '|' || c === '&') {
59 endCmd()
60 // `&&` / `||` are two chars of the same separator
61 if ((c === '&' || c === '|') && command[i + 1] === c) i++
62 continue
63 }
64 if (c === ' ' || c === '\t') { endWord(); continue }
65 cur += c; has = true
66 }
67 endCmd()
68 return out
69}
70
71/** posix join/normalise of `rel` onto `base` (undefined base = unknown cwd, kept relative). */
72export function resolveDir(base: string | undefined, rel: string): string {
73 const joined = rel.startsWith('/') || base === undefined ? rel : `${base}/${rel}`
74 const abs = joined.startsWith('/')
75 const parts: string[] = []
76 for (const p of joined.split('/')) {
77 if (p === '' || p === '.') continue
78 if (p === '..') { if (parts.length && parts[parts.length - 1] !== '..') parts.pop(); else if (!abs) parts.push('..'); continue }
79 parts.push(p)
80 }
81 return (abs ? '/' : '') + parts.join('/') || (abs ? '/' : '.')
82}
83
84const UNRESOLVABLE = /^~|[$`]/
85
86function branchFromRefspecs(specs: string[]): string | undefined {
87 // `git push origin feat` / `HEAD:feat` / `+src:refs/heads/feat`: the destination is the branch.
88 const spec = specs[0]
89 if (!spec || spec === 'HEAD') return undefined
90 const dst = spec.replace(/^\+/, '').split(':').pop() ?? ''
91 const b = dst.replace(/^refs\/heads\//, '')
92 return b && b !== 'HEAD' ? b : undefined
93}
94
95export function parsePushes(command: string, depth = 0): Push[] {
96 const pushes: Push[] = []
97 let dir: string | undefined // from earlier `cd X` in the same command
98 let isDirUnresolved = false
99 for (const raw of splitCommands(command)) {
100 let w = [...raw]
101 while (w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!) || WRAPPERS.has(w[0]!))) w.shift()
102 const prog = (w[0] ?? '').split('/').pop()
103 if (prog === 'cd' && w[1] !== undefined) {
104 if (UNRESOLVABLE.test(w[1])) isDirUnresolved = true
105 else { dir = resolveDir(dir, w[1]); isDirUnresolved = false }
106 continue
107 }
108 if (SHELLS.has(prog ?? '') && depth < 3) {
109 const ci = w.findIndex(x => /^-[A-Za-z]*c$/.test(x))
110 if (ci >= 0 && w[ci + 1] !== undefined) {
111 for (const p of parsePushes(w[ci + 1]!, depth + 1)) pushes.push({ ...p, dir: p.dir ?? dir, isDirUnresolved: p.isDirUnresolved || isDirUnresolved })
112 }
113 continue
114 }
115 if (prog === 'eval' && depth < 3 && w[1] !== undefined) {
116 for (const p of parsePushes(w.slice(1).join(' '), depth + 1)) pushes.push({ ...p, dir: p.dir ?? dir, isDirUnresolved: p.isDirUnresolved || isDirUnresolved })
117 continue
118 }
119 if (prog !== 'git') continue
120
121 // git global options
122 let i = 1
123 let gdir = dir
124 let unresolved = isDirUnresolved
125 for (; i < w.length; i++) {
126 const t = w[i]!
127 if (t === '-C' && w[i + 1] !== undefined) {
128 const d = w[++i]!
129 if (UNRESOLVABLE.test(d)) unresolved = true
130 else gdir = resolveDir(gdir, d)
131 } else if (GIT_VALUE_OPTS.has(t)) i++
132 else if (t.startsWith('-')) continue // -c k=v handled above; --no-pager, -p, --git-dir=..
133 else break
134 }
135 if (w[i] !== 'push') continue
136
137 // push options and arguments
138 const pos: string[] = []
139 let ignored = false
140 for (let j = i + 1; j < w.length; j++) {
141 const t = w[j]!
142 if (t === '--') { pos.push(...w.slice(j + 1)); break }
143 if (t === '--dry-run' || t === '-n' || t === '--delete' || t === '-d') { ignored = true; continue }
144 if (/^-[A-Za-z]+$/.test(t) && !t.startsWith('--') && /[nd]/.test(t) && t !== '-o') { ignored = true; continue } // -fn, -nf, -fd ...
145 if (PUSH_VALUE_OPTS.has(t)) { j++; continue }
146 if (t.startsWith('-')) continue
147 pos.push(t)
148 }
149 if (ignored) continue
150 const specs = pos.slice(1) // pos[0] is the remote
151 if (specs.some(s => s.startsWith(':'))) continue // delete refspec
152 pushes.push({ dir: gdir, isDirUnresolved: unresolved, branch: branchFromRefspecs(specs) })
153 }
154 return pushes
155}
156
157export const hasSkip = (command: string): boolean => SKIP_RE.test(command)
158
159// ---------------------------------------------------------------------------------------------
160// 2. Preflight outcome -> decision
161// ---------------------------------------------------------------------------------------------
162
163export type PreflightRun =
164 | { kind: 'skipped' }
165 | { kind: 'not-applicable' } // repo has no scripts/ci/preflight.sh
166 | { kind: 'unresolved-dir' }
167 | { kind: 'timeout'; seconds: number }
168 | { kind: 'error'; message: string }
169 | { kind: 'exit'; code: number; output: string }
170
171/** `isQuiet`: routine, nothing changed for the person (a pass, or a repo this mod does not apply to): no toast. */
172export type Decision = { deny?: string; context?: string[]; toast?: string; isQuiet?: boolean }
173
174export const BYPASS_LINE =
175 'Real emergency only: put PREFLIGHT_SKIP=1 in the command (PREFLIGHT_SKIP=1 git push ...) to push anyway; it is allowed with a note, and CI then runs every check.'
176
177/** Keeps a long summary readable: head, a marker, and the tail (the pass/fail count line). */
178export function trimLines(text: string, max = MAX_DENY_LINES): string {
179 const lines = text.replace(/\s+$/, '').split('\n')
180 if (lines.length <= max) return lines.join('\n')
181 const tail = 6
182 const head = max - tail - 1
183 return [...lines.slice(0, head), `... (${lines.length - head - tail} lines trimmed)`, ...lines.slice(-tail)].join('\n')
184}
185
186export function notCheckedLines(output: string): string[] {
187 return output.split('\n').filter(l => l.startsWith('NOT CHECKED')).map(l => l.replace(/\s+/g, ' ').trim())
188}
189
190export function decidePush(run: PreflightRun): Decision {
191 switch (run.kind) {
192 case 'not-applicable':
193 return {}
194 case 'skipped':
195 return { context: ['PREFLIGHT_SKIP=1 was in the command: local preflight was bypassed; CI runs every check on this push.'] }
196 case 'unresolved-dir':
197 return { context: ['could not work out which directory this push runs in (~, $VAR or $(...) in cd/-C): preflight NOT CHECKED. CI runs every check.'] }
198 case 'timeout':
199 return { context: [`preflight timed out after ${run.seconds}s: NOT CHECKED. Push allowed (the mod never blocks on its own slowness); CI runs every check.`] }
200 case 'error':
201 return { context: [`preflight could not run (${run.message}): NOT CHECKED. Push allowed; CI runs every check.`] }
202 case 'exit': {
203 if (run.code === 0) return { context: ['preflight passed (all applicable fast checks green).'], isQuiet: true }
204 if (run.code === 1) {
205 return {
206 deny: `push blocked, preflight FAILED (each failed CI round costs ~19 checks on a CPU-bound runner). Fix, commit, push again.\n` +
207 `${trimLines(run.output)}\n${BYPASS_LINE}`,
208 toast: 'push blocked: preflight failed (see the session for the summary)',
209 }
210 }
211 if (run.code === 2) {
212 const nc = notCheckedLines(run.output)
213 return { context: [`preflight passed what it could, but these were NOT CHECKED (not a pass): ${nc.length ? nc.join(' | ') : 'see scripts/ci/preflight.sh output'}. Push allowed; CI runs them.`] }
214 }
215 return { context: [`preflight exited ${run.code} (not a result it defines): NOT CHECKED. Push allowed; CI runs every check.`] }
216 }
217 }
218}
219
220// ---------------------------------------------------------------------------------------------
221// 3. update-branch guard: check runs -> allow / deny
222// ---------------------------------------------------------------------------------------------
223
224export type CheckRun = { name: string; status: string; conclusion: string | null }
225
226const OK = new Set(['success', 'neutral', 'skipped'])
227const BAD = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale'])
228
229/** Reads the GitHub MCP get_check_runs text. undefined = could not be read as a list of runs. */
230export function parseCheckRuns(text: string): CheckRun[] | undefined {
231 let v: unknown
232 try { v = JSON.parse(text) } catch { return undefined }
233 const list = Array.isArray(v) ? v : (v && typeof v === 'object' ? (v as { check_runs?: unknown }).check_runs : undefined)
234 if (!Array.isArray(list)) return undefined
235 const out: CheckRun[] = []
236 for (const r of list) {
237 if (!r || typeof r !== 'object') return undefined
238 const o = r as Record<string, unknown>
239 if (typeof o.name !== 'string' || typeof o.status !== 'string') return undefined
240 out.push({ name: o.name, status: o.status.toLowerCase(), conclusion: typeof o.conclusion === 'string' ? o.conclusion.toLowerCase() : null })
241 }
242 return out
243}
244
245const names = (rs: CheckRun[]) => rs.slice(0, 6).map(r => r.name).join(', ') + (rs.length > 6 ? `, +${rs.length - 6} more` : '')
246
247const WHY = 'CLAUDE.md section 5: update a PR branch ONCE, when it is otherwise green and ready to merge; each update re-runs the full ~19-check CI set.'
248
249/**
250 * FAIL-OPEN BY DESIGN: this guard is advisory-strength. When the check runs cannot be read (MCP error,
251 * unexpected shape, zero runs reported yet) it allows with a warning rather than block a legitimate
252 * update because of our own plumbing. Only a readable list with a failing or unfinished run denies.
253 */
254export function decideUpdateBranch(runs: CheckRun[] | undefined, readError?: string): Decision {
255 if (runs === undefined) {
256 return { context: [`could not read this PR's check runs${readError ? ` (${readError})` : ''}: update-branch guard NOT CHECKED, update allowed. ${WHY}`] }
257 }
258 if (runs.length === 0) {
259 return { context: [`the PR reports no check runs yet: update-branch guard NOT CHECKED, update allowed. ${WHY}`] }
260 }
261 const failing = runs.filter(r => r.status === 'completed' && r.conclusion !== null && BAD.has(r.conclusion))
262 if (failing.length) {
263 return { deny: `update-branch refused: ${failing.length} check(s) failing on the PR head (${names(failing)}). Fix them first. ${WHY}`, toast: `update-branch refused: ${failing.length} failing check(s)` }
264 }
265 // Not finished, or finished with no/unknown conclusion: never read as green (CI review on #1842).
266 const pending = runs.filter(r => r.status !== 'completed' || r.conclusion === null || !OK.has(r.conclusion))
267 if (pending.length) {
268 return { deny: `update-branch refused: ${pending.length} check(s) still queued/in progress (${names(pending)}). Wait until they finish green. ${WHY}`, toast: `update-branch refused: ${pending.length} check(s) not finished` }
269 }
270 return {}
271}
272
273// ---------------------------------------------------------------------------------------------
274// 4. batching nudge
275// ---------------------------------------------------------------------------------------------
276
277export const NUDGE_TEXT =
278 'second push to this branch within 10 minutes: batch fixes into ONE push; each push re-runs the full CI set (~19 checks) on a CPU-bound runner.'
279
280/** The nudge text when the previous push to the same branch was less than `windowMs` ago. */
281export function nudgeFor(prevMs: number | undefined, nowMs: number, windowMs = NUDGE_WINDOW_MS): string | undefined {
282 if (prevMs === undefined || !Number.isFinite(prevMs)) return undefined
283 const age = nowMs - prevMs
284 return age >= 0 && age < windowMs ? NUDGE_TEXT : undefined
285}
286
287/** Remember `key` at `nowMs`; drop entries older than an hour and cap the map. */
288export function remember(map: Record<string, number> | undefined, key: string, nowMs: number): Record<string, number> {
289 const kept = Object.entries(map ?? {}).filter(([k, t]) => k !== key && typeof t === 'number' && nowMs - t < 3_600_000)
290 kept.sort((a, b) => b[1] - a[1])
291 return Object.fromEntries([[key, nowMs], ...kept.slice(0, 49)])
292}
293