After a git push or create_pull_request, checks the branch's PR every 2 minutes and wakes the session once its CI has finished (naming failures), when no…

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 151 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Watch } from '../types'
5import { decide, findPr, prNumberFrom, parseCheckRuns, parsePr, parsePushes, parseRemote, POLL_MS } from './logic'
6import type { Observation, PushCmd } from './logic'
7
8// Why (LC-1896): a session must not depend on GitHub's PR events alone to learn its CI finished.
9// Decisions live in ./logic.ts; this file gathers facts (git, GitHub MCP) and acts.
10
11const DEFAULT_SERVER = 'github'
12const watches = atom({ plugin: 'pr-ci-watch', key: 'watches' } as const, [] as Watch[])
13
14// Wakes the host refused (submitted from inside a tool call) wait for the turn's end.
15const pendingWakes: string[] = []
16let ticking = false
17let busy = false
18
19function textOf(r: { content?: readonly unknown[] }): string {
20 return (r.content ?? []).map(c => String((c as { text?: unknown }).text ?? '')).join('')
21}
22
23function flush($: any) {
24 if (pendingWakes.length === 0) return
25 const lines = pendingWakes.splice(0)
26 try { $.ui.toast(lines[0]!.slice(0, 200)) } catch { /* a toast never changes a wake */ }
27 Promise.resolve()
28 .then(() => $.prompt.submit({ text: lines.join('\n') }))
29 .catch(() => { pendingWakes.unshift(...lines) })
30}
31
32async function mcp($: any, server: string, tool: string, args: Record<string, unknown>): Promise<string | undefined> {
33 try {
34 const r = await $.mcp.call(server, tool, args)
35 return r.isError ? undefined : textOf(r)
36 } catch { return undefined }
37}
38
39async function git($: any, args: string[]): Promise<string | undefined> {
40 try {
41 const r = await $.process.run(['git', ...args], { timeoutMs: 10_000 })
42 const out = String(r.stdout ?? '').trim()
43 return r.exitCode === 0 && out ? out : undefined
44 } catch { return undefined }
45}
46
47async function observe($: any, w: Watch): Promise<Observation> {
48 const pr = parsePr((await mcp($, w.server, 'pull_request_read', { method: 'get', owner: w.owner, repo: w.repo, pullNumber: w.number })) ?? '')
49 if (!pr) return { kind: 'error', detail: 'pull_request_read get unreadable' }
50 if (pr.merged || pr.state === 'closed') return { kind: 'pr', ...pr, runs: [] }
51 const runs = parseCheckRuns((await mcp($, w.server, 'pull_request_read',
52 { method: 'get_check_runs', owner: w.owner, repo: w.repo, pullNumber: w.number, perPage: 100 })) ?? '')
53 return { kind: 'pr', ...pr, runs }
54}
55
56async function tick($: any) {
57 if (busy) return
58 busy = true
59 try {
60 const now = await $.clock.now()
61 for (const w of await read($, watches)) {
62 const d = decide(w, await observe($, w), now)
63 if (d.wake) { pendingWakes.push(d.wake); flush($) }
64 const same = (x: Watch) => x.owner === w.owner && x.repo === w.repo && x.number === w.number && x.startedAt === w.startedAt
65 await update($, watches, l => (d.done ? l.filter(x => !same(x)) : l.map(x => (same(x) ? { ...x, ...d.patch } : x))))
66 }
67 } catch { /* a failed tick is retried by the next one */ } finally {
68 busy = false
69 }
70}
71
72/** Adds or replaces the watch for one PR (a new push restarts its clock), and starts the timer. */
73async function watch($: any, w: Omit<Watch, 'startedAt'>): Promise<string> {
74 const startedAt = await $.clock.now()
75 await update($, watches, l => [...l.filter(x => !(x.owner === w.owner && x.repo === w.repo && x.number === w.number)), { ...w, startedAt }])
76 if (!ticking) {
77 ticking = true
78 $.clock.every(POLL_MS, () => { void tick($) })
79 }
80 return `[mod:pr-ci-watch] Watching CI on ${w.owner}/${w.repo}#${w.number}: checked every 2 minutes; this session is ` +
81 'woken when it finishes (or registers nothing, or runs past an hour). No check-in needed for it.'
82}
83
84async function fromPush($: any, p: PushCmd): Promise<string | undefined> {
85 const at = p.dir ? ['-C', p.dir] : []
86 const remote = parseRemote((await git($, [...at, 'remote', 'get-url', p.remote ?? 'origin'])) ?? '')
87 if (!remote) return undefined
88 const branch = p.branch ?? (await git($, [...at, 'rev-parse', '--abbrev-ref', 'HEAD']))
89 const sha = await git($, [...at, 'rev-parse', p.src ?? 'HEAD'])
90 if (!branch || branch === 'HEAD' || !sha) return undefined
91 const number = findPr((await mcp($, DEFAULT_SERVER, 'list_pull_requests',
92 { ...remote, state: 'open', perPage: 100, fields: ['number', 'head', 'state'] })) ?? '', branch)
93 if (number === undefined) return undefined // no PR yet: create_pull_request starts the watch
94 return watch($, { ...remote, number, server: DEFAULT_SERVER, sha })
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 const started = await next(e)
100 await $.tool.register({
101 name: 'watch_pr',
102 description: 'Watch an existing PR\'s CI and be woken when it finishes. Pushes and create_pull_request are watched automatically; use this for a PR this session did not just push.',
103 inputSchema: {
104 type: 'object',
105 properties: { owner: { type: 'string' }, repo: { type: 'string' }, pullNumber: { type: 'number' } },
106 required: ['owner', 'repo', 'pullNumber'],
107 },
108 })
109 return started
110 }).catch(($, e, next) => next(e))
111
112 on('tool.call', { tool: 'mcp__pr-ci-watch__watch_pr' }, async ($, e: any) => {
113 const number = Number(e.pullNumber)
114 if (!e.owner || !e.repo || !Number.isInteger(number)) return { result: '[mod:pr-ci-watch] owner, repo and pullNumber are required.' }
115 return { result: await watch($, { owner: String(e.owner), repo: String(e.repo), number, server: DEFAULT_SERVER, sha: '' }) }
116 }).catch(() => ({ result: '[mod:pr-ci-watch] could not start the watch; check the PR\'s CI yourself.' }))
117
118 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
119 const ran = await next(e)
120 if (ran.deny !== undefined || ran.isError) return ran
121 const command = String((e as { command?: unknown }).command ?? '')
122 if (!/\bpush\b/.test(command)) return ran
123 const notes: string[] = []
124 for (const p of parsePushes(command)) {
125 const note = await fromPush($, p)
126 if (note && !notes.includes(note)) notes.push(note)
127 }
128 return notes.length ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
129 // Fail open: a bug here must never change the push's outcome.
130 }).catch(($, e, next) => next(e))
131
132 on('tool.call', { tool: /^mcp__.*__create_pull_request$/ }, async ($, e, next) => {
133 const ran = await next(e)
134 if (ran.deny !== undefined || ran.isError) return ran
135 const text = String((ran as any).text ?? JSON.stringify((ran as any).result ?? ''))
136 const a = e as any
137 if (!a.owner || !a.repo) return ran
138 const number = prNumberFrom(text)
139 if (number === undefined) return ran
140 const server = String(a.tool).split('__')[1] ?? DEFAULT_SERVER
141 const note = await watch($, { owner: String(a.owner), repo: String(a.repo), number, server, sha: '' })
142 return { ...ran, context: [...(ran.context ?? []), note] }
143 }).catch(($, e, next) => next(e))
144
145 on('turn.complete', async ($, e, next) => {
146 const res = await next(e)
147 flush($)
148 return res
149 })
150}
151hooks/logic.ts 191 lines1// Pure decisions for pr-ci-watch. register.ts gathers facts (git, GitHub MCP) and acts on these.
2//
3// Why (LC-1896, 2026-10-22): PR #2041's required review failed at 17:33Z and the session that
4// owned it sat idle until the operator asked. GitHub's PR events never reached it (the Claude
5// App could not see the repo under its pre-move name) and its fallback check-in was 50 minutes
6// out for a CI that finishes in 4. The platform also lists gaps where events never arrive even
7// when they work. This mod watches CI itself after a push, the way dispatch-watch watches a run.
8
9import type { Watch } from '../types'
10
11export const POLL_MS = 2 * 60_000
12export const NO_CHECKS_MS = 15 * 60_000 // nothing registered by now: a workflow did not trigger
13export const LONG_MS = 60 * 60_000
14export const ADOPT_HEAD_MS = 10 * 60_000 // the PR still shows another head: watch whatever it shows
15export const MAX_ERRORS = 5
16
17export type CheckRun = { name: string; status: string; conclusion: string | null }
18
19export type Observation =
20 | { kind: 'pr'; state: string; merged: boolean; headSha: string; runs: CheckRun[] | undefined }
21 | { kind: 'error'; detail: string }
22
23export type Decision = { done: boolean; wake?: string; patch?: Partial<Watch> }
24
25const BAD = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale'])
26// Only these read as a pass. neutral, null or anything unrecognised is never reported as green.
27const GOOD = new Set(['success', 'skipped'])
28
29// ------------------------------------------------------------------ recognising a push
30
31export type PushCmd = { dir?: string; remote?: string; src?: string; branch?: string }
32
33/** Words of one simple command, quotes removed. Enough to find `git push`; anything stranger is not watched. */
34function words(cmd: string): string[] {
35 return (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(w => w.replace(/^['"]|['"]$/g, ''))
36}
37
38/**
39 * The pushes in a Bash command. A false positive costs one PR lookup; a miss leaves the session
40 * where it is today (events or a check-in), so this stays deliberately small.
41 */
42export function parsePushes(command: string): PushCmd[] {
43 const out: PushCmd[] = []
44 let cdDir: string | undefined
45 for (const part of command.split(/&&|\|\||;|\n/)) {
46 const w = words(part.trim())
47 if (w[0] === 'cd' && w[1]) { cdDir = w[1]; continue }
48 const g = w.indexOf('git')
49 if (g < 0) continue
50 let i = g + 1
51 let dir = cdDir
52 for (; i < w.length; i++) {
53 if (w[i] === '-C' && w[i + 1]) dir = w[++i]
54 else if (w[i] === '-c' && w[i + 1]) i++
55 else if (!w[i]!.startsWith('-')) break
56 }
57 if (w[i] !== 'push') continue
58 const rest = w.slice(i + 1)
59 if (rest.some(t => t === '--dry-run' || t === '-n' || t === '--delete' || t === '-d' || t === '--tags')) continue
60 const pos = rest.filter(t => !t.startsWith('-'))
61 const spec = (pos[1] ?? '').replace(/^\+/, '')
62 if (spec.startsWith(':')) continue // deletes a branch
63 const [src, dst] = spec.includes(':') ? spec.split(':', 2) as [string, string] : [spec, spec]
64 const branch = dst && dst !== 'HEAD' ? dst.replace(/^refs\/heads\//, '') : undefined
65 out.push({ dir, remote: pos[0], src: src || undefined, branch })
66 }
67 return out
68}
69
70/** owner/repo from a remote URL (https, ssh, or a proxy path ending in owner/repo). */
71export function parseRemote(url: string): { owner: string; repo: string } | undefined {
72 const m = url.trim().match(/[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
73 return m ? { owner: m[1]!, repo: m[2]! } : undefined
74}
75
76// ------------------------------------------------------------------ reading MCP results
77
78function json(text: string): unknown {
79 try { return JSON.parse(text) } catch { return undefined }
80}
81
82/** The open PR whose head branch is `branch`, from list_pull_requests text. */
83export function findPr(text: string, branch: string): number | undefined {
84 const v = json(text)
85 if (!Array.isArray(v)) return undefined
86 const hit = v.find(p => p && typeof p === 'object' && (p as any).head?.ref === branch && (p as any).state !== 'closed')
87 return hit && typeof (hit as any).number === 'number' ? (hit as any).number : undefined
88}
89
90/**
91 * The new PR's number from a create_pull_request result: the JSON `number` field, else the LAST
92 * /pull/N URL (the PR's own url comes after any URL quoted in the title or body).
93 */
94export function prNumberFrom(text: string): number | undefined {
95 const v = json(text) as any
96 if (v && typeof v === 'object' && Number.isInteger(v.number)) return v.number
97 const all = [...text.matchAll(/\/pull\/(\d+)/g)]
98 return all.length ? Number(all[all.length - 1]![1]) : undefined
99}
100
101/** state, merged and head sha from pull_request_read get text. */
102export function parsePr(text: string): { state: string; merged: boolean; headSha: string } | undefined {
103 const v = json(text) as any
104 if (!v || typeof v !== 'object' || typeof v.head?.sha !== 'string') return undefined
105 return { state: String(v.state ?? ''), merged: v.merged === true || typeof v.merged_at === 'string', headSha: v.head.sha }
106}
107
108/** get_check_runs text. undefined = not readable as a list of runs. */
109export function parseCheckRuns(text: string): CheckRun[] | undefined {
110 const v = json(text)
111 const list = Array.isArray(v) ? v : (v && typeof v === 'object' ? (v as { check_runs?: unknown }).check_runs : undefined)
112 if (!Array.isArray(list)) return undefined
113 const out: CheckRun[] = []
114 for (const r of list) {
115 if (!r || typeof r !== 'object' || typeof (r as any).name !== 'string' || typeof (r as any).status !== 'string') return undefined
116 out.push({ name: (r as any).name, status: (r as any).status.toLowerCase(),
117 conclusion: typeof (r as any).conclusion === 'string' ? (r as any).conclusion.toLowerCase() : null })
118 }
119 return out
120}
121
122// ------------------------------------------------------------------ the decision
123
124const label = (w: Watch) => `${w.owner}/${w.repo}#${w.number}`
125const short = (sha: string) => sha.slice(0, 7)
126const list = (rs: CheckRun[]) => rs.slice(0, 6).map(r => `${r.name} (${r.conclusion})`).join('; ') + (rs.length > 6 ? `; +${rs.length - 6} more` : '')
127
128export function decide(w: Watch, o: Observation, now: number): Decision {
129 const P = `[mod:pr-ci-watch] ${label(w)}`
130 if (o.kind === 'error') {
131 const errors = (w.errors ?? 0) + 1
132 if (errors < MAX_ERRORS) return { done: false, patch: { errors } }
133 return { done: true, wake: `${P}: could not read the PR or its checks ${errors} times in a row (${o.detail}); stopped watching. Check its CI yourself now.` }
134 }
135 if (o.merged) return { done: true, wake: `${P} merged. Do what the merge calls for (Jira, mirror, follow-ups).` }
136 if (o.state === 'closed') return { done: true, wake: `${P} was closed without merging.` }
137
138 const base: Partial<Watch> = { errors: 0 }
139 // Right after a push the PR can still show the previous head, whose finished runs would read as done.
140 // A watch made from create_pull_request or watch_pr has no pushed sha: it takes the PR's head.
141 if (!w.sha) {
142 base.sha = o.headSha
143 } else if (o.headSha !== w.sha) {
144 if (now - w.startedAt < ADOPT_HEAD_MS) return { done: false, patch: base }
145 base.sha = o.headSha // someone else pushed, or the push never landed: watch what the PR shows
146 base.lastSig = undefined
147 }
148 const sha = base.sha ?? w.sha
149 const runs = o.runs
150 // Unreadable check runs are a read error, never a quiet wait: otherwise the session could idle forever.
151 if (runs === undefined) {
152 const errors = (w.errors ?? 0) + 1
153 if (errors < MAX_ERRORS) return { done: false, patch: { ...base, errors } }
154 return { done: true, wake: `${P}: could not read the check runs ${errors} times in a row; stopped watching. Check its CI yourself now.` }
155 }
156
157 if (runs.length === 0) {
158 if (!w.warnedNone && now - w.startedAt >= NO_CHECKS_MS) {
159 return { done: false, patch: { ...base, warnedNone: true },
160 wake: `${P}: no checks have registered on ${short(sha)} after ${Math.round((now - w.startedAt) / 60_000)} min. A workflow may not have triggered; look at the PR's Actions.` }
161 }
162 return { done: false, patch: base }
163 }
164
165 const running = runs.filter(r => r.status !== 'completed')
166 if (running.length > 0) {
167 const patch = { ...base, lastSig: undefined }
168 if (!w.warnedLong && now - w.startedAt >= LONG_MS) {
169 return { done: false, patch: { ...patch, warnedLong: true },
170 wake: `${P}: CI on ${short(sha)} is still running after ${Math.round((now - w.startedAt) / 60_000)} min (${running.slice(0, 4).map(r => r.name).join(', ')}). A queued job may be waiting on a busy runner; check before assuming it is dead.` }
171 }
172 return { done: false, patch }
173 }
174
175 // Everything finished. Wait for the same picture twice: a slow workflow can still register a run.
176 const sig = `${sha}|` + runs.map(r => `${r.name}=${r.conclusion}`).sort().join(',')
177 if (w.lastSig !== sig) return { done: false, patch: { ...base, lastSig: sig } }
178
179 const bad = runs.filter(r => r.conclusion !== null && BAD.has(r.conclusion))
180 if (bad.length > 0) {
181 return { done: true, wake: `${P}: CI finished on ${short(sha)}: ${bad.length} of ${runs.length} checks failed: ${list(bad)}. ` +
182 'This PR is yours to drive: read the failure (delegate the log), fix and push.' }
183 }
184 const unclear = runs.filter(r => r.conclusion === null || !GOOD.has(r.conclusion))
185 if (unclear.length > 0) {
186 return { done: true, wake: `${P}: CI finished on ${short(sha)} but ${unclear.length} of ${runs.length} checks did not clearly pass: ` +
187 `${list(unclear)}. Read them before treating the PR as green.` }
188 }
189 return { done: true, wake: `${P}: CI finished on ${short(sha)}: all ${runs.length} checks passed. Do what the PR needs next (review threads, ready-for-review, auto-merge).` }
190}
191types/index.d.ts 23 lines1/** One PR whose CI this session is waiting on after a push. */
2export type Watch = {
3 owner: string
4 repo: string
5 number: number
6 /** GitHub MCP server the PR is read through (e.g. `github`). */
7 server: string
8 /** The head this session pushed; replaced by the PR's head after ADOPT_HEAD_MS. */
9 sha: string
10 startedAt: number
11 /** Signature of the last all-finished picture; the same one twice means done. */
12 lastSig?: string
13 errors?: number
14 warnedNone?: boolean
15 warnedLong?: boolean
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'pr-ci-watch': { watches: Watch[] }
21 }
22}
23