Ship-pipeline band (PR, CI, merge, prod deployment, live) and a receipt line under each turn

Five Claude Code mods built from how I actually use Claude Code: four to six sessions at a time, mostly in one repo, a lot of PRs going to production, and a pile of rules I keep forgetting to check.
Claude built all five. The full story, with screenshots, videos and the bugs a real run caught, is on my site: I asked Claude to read 30 of my Claude Code sessions and build the mods I needed
![]()
This is what I gave Claude Code. It read my last 30 sessions, came back with ten ideas, and built all ten as these five mods.
look at the last 30 sessions and see how we use claude to work - then look at https://code.claude.com/docs/en/plugins/mods/overview and suggest 10 possible mods for our workflow that would help me be better informed as to what each session is doing.
Run it on your own sessions. Your ten ideas will probably come out different from mine, and I'd like to see them.
| Mod | What it gives you | Commands |
|---|---|---|
| session-fleet | A board of every live session, a "needs you" row when another session is blocked on you, and a guard when two sessions write to the same worktree | /fleet |
| ship-tracker | A row per PR: CI → merged → production deployment → live, a warning when a merge never deploys, and a receipt line after every answer | /ship-track <pr> |
| session-hygiene | Configurable tripwires (typecheck before push, review before push, mutation-check new tests and more), plus a handoff card of loose ends from earlier sessions | /handoff, /handoff clear |
| session-activity | A ledger of everything that left the machine, a hold on database writes, and what each session is waiting on | /ledger, /waiting |
| context-gauge | Context fill in the status line, warnings before compaction, and a snapshot of in-flight work that survives compaction | /context-log |
They work on their own. Install any one, or all five.
You need a Claude Code version with mods (see the mods docs), git, and for ship-tracker and the handoff card, the GitHub CLI signed in.
A mod is code that runs with your permissions inside Claude Code. Read it before you install it. Each one is a single hooks/register.tsx file, and claude plugin validate <folder> lists exactly which events it hooks and what it calls.
In a Claude Code session:
/plugin marketplace add bennewton999/claude-code-mods
/plugin install session-fleet@bennewton-mods
/plugin install ship-tracker@bennewton-mods
/plugin install session-hygiene@bennewton-mods
/plugin install session-activity@bennewton-mods
/plugin install context-gauge@bennewton-mods
Then /reload-plugins, or start a new session.
git clone https://github.com/bennewton999/claude-code-mods ~/claude-code-mods
Load them in every session by adding the folders to env in ~/.claude/settings.json (colon-separated):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/session-fleet:/Users/you/claude-code-mods/ship-tracker:/Users/you/claude-code-mods/session-hygiene:/Users/you/claude-code-mods/session-activity:/Users/you/claude-code-mods/context-gauge",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
Or try one for a single session:
claude --plugin-dir ~/claude-code-mods/session-fleet
CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop app sessions hot-reload a mod when its files change (an interactive terminal session already does). A session reads it at startup, so sessions that were already open pick up the mods, and the watch, after one restart.
Every session writes one record to a key-value store that all sessions on the machine share ($.store), with a heartbeat every 30 seconds. Three minutes without a heartbeat and the session counts as gone.
/fleet opens a pane with every live session: a status dot, its first prompt (there's no session title API), the worktrees it's touching, branch, PR, last action and how long ago. Waiting sessions sort to the top, and a worktree two live sessions share gets a ⚠.projectPills to Label:regex:color entries separated by ;, the regex tested against the repo folder name: "pluginConfigs": {
"session-fleet": {
"options": { "projectPills": "Work:acme|billing:#2563eb; Blog:blog:#059669" }
}
}
![]()
gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.no prod deploy!. That's the silent Vercel git-trigger failure that got me before.![]()
Tripwires for rules you keep forgetting. When one trips you get a transcript line and a toast, and Claude gets a reminder with the tool result so it can act on it.
Every rule is a plugin option. You set them on the install screen, or later in /config. Out of the box only the two generic rules are on; the rest stay off until you point them at your repos.
| Option | Default | What it does |
|---|---|---|
holdEnvSourcing | on | Asks Run it or Stop before a command sources a .env file into the shell |
mutationCheckTests | on | When Claude writes a new test file, reminds it to prove the test fails without the code it covers |
reviewBeforePush | off | Flags a git push with edits made since the last local /code-review |
uncheckedRepos | none | Path fragments (/my-app/) of repos whose CI doesn't typecheck or build. There it flags a push after TypeScript edits with no typecheck, and a merge after package.json or next.config changes with no local build |
middlewareRouteRepos | none | Next.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it |
bannedStrings | none | Case-insensitive text that should never be written into a file |
supabase | off | Asks before a command uses a service-role key; after apply_migration, reminds Claude to run NOTIFY pgrst, 'reload schema' and to revoke default grants on new tables |
If you load the mod from a clone instead of the marketplace, set the same options in ~/.claude/settings.json:
{
"pluginConfigs": {
"session-hygiene": {
"options": {
"uncheckedRepos": ["/my-app/"],
"bannedStrings": ["lorem ipsum"],
"supabase": true
}
}
}
}
The handoff card: after each turn a session saves its open loops (open PRs, uncommitted or unpushed worktrees, a question it left you). A new session lists loops from sessions that ended or went quiet, re-checked live first. /handoff shows them again, /handoff clear dismisses them.
![]()
/ledger lists everything that left the machine: MCP write tools, git push, gh pr create and merge, gh api writes, Vercel deploys, curl writes, publishes, SQL writes. A button switches to all sessions in the last 24 hours. Reads stay out of it.execute_sql that writes (INSERT, UPDATE, DELETE, DDL, GRANT…) stops for a Run it or Stop question. SELECTs and NOTIFY pass. Words inside comments and quoted strings don't count.Thinking · 2 bg · agent 3m…), and a row above the prompt lists background commands, agents, workflows and monitors still running after Claude stops. /waiting lists them, /waiting clear resets.![]()
ctx 62% · 124k/200k · compacted 1×./context-log lists every compaction this session with its snapshot.![]()
Everything these mods record stays on your machine in Claude Code's per-plugin store. The only network calls are gh requests to GitHub for PR and deployment state (ship-tracker, and the handoff card's re-check).
/ledger reported the pane as opened but it never drew. It works in fresh sessions; the built-in diff pane seemed to be in front of it.I'm Ben Newton. I write about building with AI at benenewton.com, and I'm building BlackOps Center, the platform that runs my site, my posts and the notes these Claude sessions write into. If you want to see it, start at blackopscenter.com/start.
Claude Code wrote the mods with its built-in plugin-authoring skill. Issues and PRs are welcome.
MIT licensed.
hooks/register.tsx 538 lines1import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
2
3// ship-tracker:
4// 3. a band above the prompt following each PR this session touched:
5// web: CI → merged → production deployment of the merge commit → live
6// iOS: CI → merged → TestFlight build (first upload after the merge)
7// → App Store (review → live), read from the repo's read-only
8// `fastlane ios ship_status` lane
9// 7. a receipt line under each answer: duration, tools, files edited,
10// commands, outward-facing writes, subagents, context fill
11
12const POLL_MS = 45_000
13const MISSING_AFTER_MS = 15 * 60_000 // merged with no prod deployment by now = trigger died
14const SHOW_DONE_MS = 30 * 60_000 // keep a finished PR in the band this long
15const IOS_POLL_MS = 3 * 60_000 // App Store Connect via fastlane: slower, and states change slowly
16
17type Stage = 'todo' | 'run' | 'ok' | 'fail'
18type Ci = 'none' | 'running' | 'pass' | 'fail'
19type Deploy = 'none' | 'pending' | 'success' | 'failure' | 'missing'
20
21type Pr = {
22 repo: string
23 number: number
24 title: string
25 state: 'OPEN' | 'MERGED' | 'CLOSED'
26 ci: Ci
27 sha: string | null
28 mergedAt: number | null
29 deploy: Deploy
30 deployUrl: string | null
31 doneAt: number | null
32 hidden: boolean
33 // iOS repos (no deployments): App Store Connect state for the first
34 // TestFlight build uploaded after the merge.
35 kind?: 'web' | 'ios'
36 tfBuild?: string | null
37 tfState?: string | null
38 storeState?: string | null
39 storeVersion?: string | null
40 iosError?: string | null
41}
42
43type ShipStatus = {
44 builds: Array<{ version: string; build: string; state: string; uploaded: string; expired: boolean }>
45 versions: Array<{ version: string; state: string; build: string | null }>
46}
47
48let sessionKey = ''
49let prs: Pr[] = []
50let timers: Timer[] = []
51let polling = false
52const kinds = new Map<string, 'web' | 'ios'>() // repo -> kind
53const shipStatus = new Map<string, { at: number; data: ShipStatus | null; error: string | null }>()
54
55// Per-turn receipt counters
56let tools = 0
57let failed = 0
58let cmds = 0
59let agents = 0
60let files = new Set<string>()
61let outward: string[] = []
62
63const OUTWARD_MCP =
64 /^(post|patch|put|create|update|delete|send|publish|apply|execute|upload|trash|merge|fire|schedule|bind|set|reply|forward|share|deploy|log)_/
65const PR_URL = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g
66const PR_REF = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g
67
68const short = (s: string, n: number) => {
69 const one = s.replace(/\s+/g, ' ').trim()
70 return one.length > n ? one.slice(0, n - 1) + '…' : one
71}
72
73const mins = (ms: number) => {
74 const s = Math.max(0, Math.round(ms / 1000))
75 if (s < 60) return `${s}s`
76 if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? `${s % 60}s` : ''}`
77 return `${Math.floor(s / 3600)}h${Math.round((s % 3600) / 60)}m`
78}
79
80async function persist($: Engine) {
81 if (sessionKey) await $.store.set(sessionKey, prs)
82}
83
84function track(repo: string, number: number) {
85 if (prs.some(p => p.repo === repo && p.number === number)) return false
86 prs.push({
87 repo,
88 number,
89 title: '',
90 state: 'OPEN',
91 ci: 'none',
92 sha: null,
93 mergedAt: null,
94 deploy: 'none',
95 deployUrl: null,
96 doneAt: null,
97 hidden: false,
98 })
99 prs = prs.slice(-6)
100 return true
101}
102
103function discover(text: string, allowRefs: boolean) {
104 let found = false
105 for (const m of text.matchAll(PR_URL)) found = track(m[1]!, Number(m[2])) || found
106 if (allowRefs) for (const m of text.matchAll(PR_REF)) found = track(m[1]!, Number(m[2])) || found
107 return found
108}
109
110async function gh($: Engine, args: string[]): Promise<unknown> {
111 const r = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
112 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.exitCode}`)
113 return JSON.parse(r.stdout)
114}
115
116type Check = { status?: string; conclusion?: string; state?: string }
117
118function ciOf(checks: Check[]): Ci {
119 if (checks.length === 0) return 'none'
120 const bad = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
121 if (checks.some(c => bad.has(c.conclusion ?? '') || bad.has(c.state ?? ''))) return 'fail'
122 if (checks.some(c => (c.status && c.status !== 'COMPLETED') || c.state === 'PENDING' || c.state === 'EXPECTED'))
123 return 'running'
124 return 'pass'
125}
126
127async function kindOf($: Engine, repo: string): Promise<'web' | 'ios'> {
128 const known = kinds.get(repo)
129 if (known) return known
130 let kind: 'web' | 'ios' = 'web'
131 try {
132 const langs = (await gh($, ['api', `repos/${repo}/languages`])) as Record<string, number>
133 const top = Object.entries(langs).sort((a, b) => b[1] - a[1])[0]?.[0]
134 if (top === 'Swift' || top === 'Objective-C') kind = 'ios'
135 } catch {
136 // unknown: treat as web, the old behaviour
137 }
138 kinds.set(repo, kind)
139 return kind
140}
141
142/** The session's checkout of `repo`, if the session is in one. */
143async function checkoutOf($: Engine, repo: string): Promise<string | null> {
144 const cwd = await $.session.cwd()
145 const top = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
146 if (top.exitCode !== 0) return null
147 const root = top.stdout.trim()
148 const origin = await $.process.run(['git', '-C', root, 'remote', 'get-url', 'origin'], { timeoutMs: 5000 })
149 const slug = origin.stdout.trim().replace(/\.git$/, '')
150 return slug.toLowerCase().endsWith(repo.toLowerCase()) ? root : null
151}
152
153/** App Store Connect state via the repo's `ship_status` lane, cached per repo. */
154async function iosStatusOf($: Engine, repo: string, now: number) {
155 const cached = shipStatus.get(repo)
156 if (cached && now - cached.at < IOS_POLL_MS) return cached
157 let entry: { at: number; data: ShipStatus | null; error: string | null }
158 const root = await checkoutOf($, repo)
159 if (!root) {
160 entry = { at: now, data: null, error: 'no local checkout in this session' }
161 } else {
162 const r = await $.process.run(['fastlane', 'ios', 'ship_status'], {
163 cwd: root,
164 timeoutMs: 120_000,
165 env: { LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', FASTLANE_SKIP_UPDATE_CHECK: '1', FASTLANE_HIDE_CHANGELOG: '1' },
166 })
167 const line = r.stdout.split('\n').find(l => l.includes('SHIP_STATUS_JSON:'))
168 if (line) {
169 entry = { at: now, data: JSON.parse(line.slice(line.indexOf('SHIP_STATUS_JSON:') + 17)) as ShipStatus, error: null }
170 } else {
171 const noLane = /Could not find lane|ship_status/.test(r.stdout + r.stderr) && r.exitCode !== 0
172 entry = { at: now, data: null, error: noLane ? 'no ship_status lane in the Fastfile' : `fastlane exited ${r.exitCode}` }
173 }
174 }
175 shipStatus.set(repo, entry)
176 return entry
177}
178
179const STORE_LIVE = new Set(['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION'])
180const STORE_REVIEW = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW'])
181const STORE_APPROVED = new Set(['PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'PROCESSING_FOR_APP_STORE'])
182const STORE_REJECTED = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY'])
183
184async function refreshIos($: Engine, p: Pr, now: number) {
185 const before = { tfBuild: p.tfBuild, tfState: p.tfState, storeState: p.storeState }
186 const st = await iosStatusOf($, p.repo, now)
187 p.iosError = st.error
188 if (!st.data || !p.mergedAt) return
189 // The first build uploaded after the merge is the first one carrying it.
190 const merged = p.mergedAt
191 const build = st.data.builds
192 .filter(b => Date.parse(b.uploaded) >= merged)
193 .sort((a, b) => Date.parse(a.uploaded) - Date.parse(b.uploaded))[0]
194 p.tfBuild = build?.build ?? null
195 p.tfState = build?.state ?? null
196 const n = build ? Number(build.build) : NaN
197 const version = Number.isNaN(n) ? undefined : st.data.versions.find(v => v.build !== null && Number(v.build) >= n)
198 p.storeState = version?.state ?? null
199 p.storeVersion = version?.version ?? build?.version ?? null
200
201 if ((p.storeState && (STORE_LIVE.has(p.storeState) || STORE_REJECTED.has(p.storeState))) && !p.doneAt) p.doneAt = now
202
203 const tag = `#${p.number}`
204 if (!before.tfBuild && p.tfBuild) $.ui.toast(`${tag}: in TestFlight build ${p.tfBuild}${p.tfState === 'VALID' ? '' : ', processing'}`)
205 if (before.tfState !== p.tfState && p.tfState === 'VALID' && before.tfBuild)
206 $.ui.toast(`${tag}: build ${p.tfBuild} processed, ready to test`, { timeoutMs: 10_000 })
207 if (before.tfState !== p.tfState && (p.tfState === 'FAILED' || p.tfState === 'INVALID'))
208 $.ui.toast(`${tag}: TestFlight build ${p.tfBuild} ${p.tfState}`, { timeoutMs: 15_000 })
209 if (before.storeState !== p.storeState && p.storeState) {
210 if (STORE_REVIEW.has(p.storeState)) $.ui.toast(`${tag}: ${p.storeVersion} in App Review`)
211 if (p.storeState === 'PENDING_DEVELOPER_RELEASE') $.ui.toast(`${tag}: ${p.storeVersion} approved, waiting for you to release`, { timeoutMs: 15_000 })
212 if (STORE_LIVE.has(p.storeState)) $.ui.toast(`${tag}: live in the App Store`, { timeoutMs: 10_000 })
213 if (STORE_REJECTED.has(p.storeState)) $.ui.toast(`${tag}: ${p.storeVersion} rejected (${p.storeState})`, { timeoutMs: 15_000 })
214 }
215}
216
217async function refreshOne($: Engine, p: Pr, now: number) {
218 const before = { ci: p.ci, state: p.state, deploy: p.deploy }
219 const v = (await gh($, [
220 'pr', 'view', String(p.number), '-R', p.repo,
221 '--json', 'number,title,state,mergeCommit,mergedAt,statusCheckRollup',
222 ])) as {
223 title: string
224 state: Pr['state']
225 mergeCommit: { oid: string } | null
226 mergedAt: string | null
227 statusCheckRollup: Check[] | null
228 }
229 p.title = v.title
230 p.state = v.state
231 if (p.state !== 'MERGED') p.ci = ciOf(v.statusCheckRollup ?? [])
232 if (p.state === 'MERGED') {
233 p.sha = v.mergeCommit?.oid ?? null
234 p.mergedAt = v.mergedAt ? Date.parse(v.mergedAt) : p.mergedAt ?? now
235 if (p.ci === 'running' || p.ci === 'none') p.ci = ciOf(v.statusCheckRollup ?? [])
236 }
237
238 if (!p.kind) {
239 p.kind = await kindOf($, p.repo)
240 if (p.kind === 'ios') {
241 // iOS has no deployments: drop whatever the web path concluded.
242 p.deploy = 'none'
243 p.deployUrl = null
244 p.doneAt = null
245 }
246 }
247 if (p.kind === 'ios') {
248 if (p.state === 'MERGED') await refreshIos($, p, now)
249 else if (p.state === 'CLOSED' && !p.doneAt) p.doneAt = now
250 const tag = `#${p.number}`
251 if (before.ci !== p.ci && p.ci === 'fail') $.ui.toast(`${tag}: CI failed`, { timeoutMs: 10_000 })
252 if (before.ci !== p.ci && p.ci === 'pass' && p.state === 'OPEN') $.ui.toast(`${tag}: CI green`)
253 if (before.state !== p.state && p.state === 'MERGED') $.ui.toast(`${tag}: merged, waiting for a TestFlight upload`)
254 return
255 }
256
257 if (p.state === 'MERGED' && p.sha) {
258 const deps = (await gh($, ['api', `repos/${p.repo}/deployments?sha=${p.sha}&per_page=20`])) as Array<{
259 environment: string
260 statuses_url: string
261 }>
262 const prod = deps.find(d => /prod/i.test(d.environment))
263 if (!prod) {
264 p.deploy = now - (p.mergedAt ?? now) > MISSING_AFTER_MS ? 'missing' : 'pending'
265 } else {
266 const st = (await gh($, ['api', `${prod.statuses_url}?per_page=1`])) as Array<{
267 state: string
268 environment_url?: string
269 target_url?: string
270 }>
271 const s = st[0]
272 p.deploy = !s
273 ? 'pending'
274 : s.state === 'success'
275 ? 'success'
276 : s.state === 'failure' || s.state === 'error'
277 ? 'failure'
278 : 'pending'
279 p.deployUrl = s?.environment_url || s?.target_url || null
280 }
281 }
282
283 const finished =
284 p.state === 'CLOSED' || p.deploy === 'success' || p.deploy === 'failure' ||
285 (p.deploy === 'missing' && now - (p.mergedAt ?? now) > 4 * MISSING_AFTER_MS)
286 if (finished && !p.doneAt) p.doneAt = now
287
288 // Toast the transitions worth looking up for
289 const tag = `#${p.number}`
290 if (before.ci !== p.ci && p.ci === 'fail') $.ui.toast(`${tag}: CI failed`, { timeoutMs: 10_000 })
291 if (before.ci !== p.ci && p.ci === 'pass' && p.state === 'OPEN') $.ui.toast(`${tag}: CI green`)
292 if (before.state !== p.state && p.state === 'MERGED') $.ui.toast(`${tag}: merged, waiting for the production build`)
293 if (before.deploy !== p.deploy && p.deploy === 'failure')
294 $.ui.toast(`${tag}: production deployment FAILED`, { timeoutMs: 15_000 })
295 if (before.deploy !== p.deploy && p.deploy === 'missing')
296 $.ui.toast(`${tag}: merged ${mins(now - (p.mergedAt ?? now))} ago and no production deployment yet. Check the Vercel git trigger.`, {
297 timeoutMs: 15_000,
298 })
299 if (before.deploy !== p.deploy && p.deploy === 'success') $.ui.toast(`${tag}: live in production`, { timeoutMs: 8000 })
300}
301
302async function poll($: Engine) {
303 if (polling) return
304 polling = true
305 try {
306 const now = await $.clock.now()
307 let changed = false
308 for (const p of prs) {
309 // Unclassified PRs (saved before iOS support) get one more look even
310 // if the old "no prod deployment" timeout already finished them.
311 if (p.hidden || (p.doneAt && p.kind)) continue
312 try {
313 await refreshOne($, p, now)
314 changed = true
315 } catch (err) {
316 $.ui.log(`ship-tracker: #${p.number}: ${String(err).slice(0, 200)}`, { to: 'debug' })
317 }
318 }
319 if (changed) {
320 await persist($)
321 $.ui.invalidate('ui.render')
322 }
323 } finally {
324 polling = false
325 }
326}
327
328const stageCi = (p: Pr): Stage =>
329 p.ci === 'pass' ? 'ok' : p.ci === 'fail' ? 'fail' : p.ci === 'running' ? 'run' : 'todo'
330const stageMerge = (p: Pr): Stage => (p.state === 'MERGED' ? 'ok' : p.state === 'CLOSED' ? 'fail' : 'todo')
331const stageBuild = (p: Pr): Stage =>
332 p.deploy === 'success' ? 'ok' : p.deploy === 'failure' || p.deploy === 'missing' ? 'fail' : p.state === 'MERGED' ? 'run' : 'todo'
333const stageLive = (p: Pr): Stage => (p.deploy === 'success' ? 'ok' : p.deploy === 'failure' ? 'fail' : 'todo')
334
335const stageTf = (p: Pr): Stage =>
336 p.tfState === 'VALID'
337 ? 'ok'
338 : p.tfState === 'FAILED' || p.tfState === 'INVALID'
339 ? 'fail'
340 : p.state === 'MERGED' && !p.iosError
341 ? 'run'
342 : 'todo'
343const stageStore = (p: Pr): Stage => {
344 const s = p.storeState
345 if (!s) return 'todo'
346 if (STORE_LIVE.has(s)) return 'ok'
347 if (STORE_REJECTED.has(s)) return 'fail'
348 if (STORE_REVIEW.has(s) || STORE_APPROVED.has(s)) return 'run'
349 return 'todo'
350}
351const tfLabel = (p: Pr, now: number) => {
352 if (p.state !== 'MERGED') return 'TestFlight'
353 if (p.iosError) return 'TestFlight ?'
354 if (!p.tfBuild) return `TestFlight upload ${p.mergedAt ? mins(now - p.mergedAt) : ''}`.trim()
355 if (p.tfState === 'VALID') return `TestFlight ${p.tfBuild}`
356 if (p.tfState === 'PROCESSING') return `TestFlight ${p.tfBuild} processing`
357 return `TestFlight ${p.tfBuild} ${String(p.tfState ?? '').toLowerCase()}`
358}
359const storeLabel = (p: Pr) => {
360 const s = p.storeState
361 if (!s) return 'App Store'
362 if (STORE_LIVE.has(s)) return 'App Store live'
363 if (STORE_REVIEW.has(s)) return `${p.storeVersion ?? ''} in review`.trim()
364 if (s === 'PENDING_DEVELOPER_RELEASE') return `${p.storeVersion ?? ''} approved: release it`.trim()
365 if (STORE_APPROVED.has(s)) return `${p.storeVersion ?? ''} releasing`.trim()
366 if (STORE_REJECTED.has(s)) return `${p.storeVersion ?? ''} rejected`.trim()
367 return `App Store (${p.storeVersion ?? ''} ${s.toLowerCase().replace(/_/g, ' ')})`.replace(' )', ')')
368}
369
370const MARK: Record<Stage, string> = { todo: '○', run: '●', ok: '✓', fail: '✗' }
371const COLOR: Record<Stage, string> = { todo: 'gray', run: 'yellow', ok: 'green', fail: 'red' }
372
373export const register: Register = on => {
374 on('session.start', async ($, e, next) => {
375 sessionKey = `prs:${await $.session.id()}`
376 prs = ((await $.store.get(sessionKey)) as Pr[] | undefined) ?? []
377 for (const t of timers) t.cancel()
378 timers = [$.clock.every(POLL_MS, () => void poll($))]
379 await $.command.register({
380 name: 'ship-track',
381 description: 'Track a PR in the ship band: /ship-track 1107 or /ship-track owner/repo#1107',
382 argumentHint: '<number | owner/repo#number | url>',
383 })
384 void poll($)
385 return next(e)
386 })
387
388 on('session.end', async ($, e, next) => {
389 for (const t of timers) t.cancel()
390 timers = []
391 if (sessionKey) await $.store.delete(sessionKey)
392 return next(e)
393 })
394
395 on('command.run', { command: 'ship-track' }, async ($, e) => {
396 const arg = (e.args ?? '').trim()
397 if (!discover(arg, true)) {
398 const n = arg.match(/^#?(\d+)$/)?.[1]
399 if (!n) return { text: 'Usage: /ship-track <number | owner/repo#number | PR url>' }
400 try {
401 const r = (await gh($, ['repo', 'view', '--json', 'nameWithOwner'])) as { nameWithOwner: string }
402 track(r.nameWithOwner, Number(n))
403 } catch {
404 return { text: 'Not in a GitHub repo here; pass owner/repo#number.' }
405 }
406 }
407 await persist($)
408 void poll($)
409 return { text: 'Tracking it in the ship band.' }
410 })
411
412 on('turn.start', async ($, e, next) => {
413 tools = failed = cmds = agents = 0
414 files = new Set()
415 outward = []
416 return next(e)
417 })
418
419 on('tool.call', async ($, e, next) => {
420 const call = e as unknown as { tool: string } & Record<string, unknown>
421 const ran = await next(e)
422 if (e.agentId !== undefined) return ran
423
424 tools += 1
425 if (ran.isError) failed += 1
426 const fp = (call.file_path ?? call.notebook_path) as string | undefined
427 if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(call.tool) && typeof fp === 'string' && !ran.isError)
428 files.add(fp.split('/').pop() ?? fp)
429 if (call.tool === 'Agent' || call.tool === 'Task') agents += 1
430
431 let found = false
432 if (call.tool === 'Bash' && typeof call.command === 'string') {
433 cmds += 1
434 const c = call.command
435 if (!ran.isError) {
436 if (/\bgh\s+pr\s+create\b/.test(c)) outward.push('PR opened')
437 if (/\bgh\s+pr\s+merge\b/.test(c)) outward.push('PR merged')
438 if (/\bgit\s+push\b/.test(c)) outward.push('git push')
439 if (/\bvercel\b.*--prod\b/.test(c)) outward.push('vercel --prod')
440 }
441 if (/\bgh\s+pr\b/.test(c)) found = discover(`${c}\n${ran.text ?? ''}`, true)
442 } else if (call.tool.startsWith('mcp__')) {
443 const name = call.tool.split('__').pop() ?? call.tool
444 if (OUTWARD_MCP.test(name) && !ran.isError) outward.push(name)
445 if (/ccd_pr|github/i.test(call.tool)) found = discover(`${JSON.stringify(call)}\n${ran.text ?? ''}`, false)
446 }
447 if (found) {
448 await persist($)
449 void poll($)
450 }
451 return ran
452 })
453
454 // 7. Turn receipt
455 on('turn.complete', async ($, e, next) => {
456 const r = await next(e)
457 if (e.agentId !== undefined) return r
458 if (tools === 0 && e.durationMs < 20_000) return r
459
460 const parts = [`⏱ ${mins(e.durationMs)}`, `${tools} tool${tools === 1 ? '' : 's'}`]
461 if (files.size) {
462 const names = [...files]
463 parts.push(`edited ${names.length}: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`)
464 }
465 if (cmds) parts.push(`${cmds} cmd${cmds === 1 ? '' : 's'}`)
466 if (failed) parts.push(`${failed} failed`)
467 if (outward.length) {
468 const counts = new Map<string, number>()
469 for (const o of outward) counts.set(o, (counts.get(o) ?? 0) + 1)
470 parts.push(`out: ${[...counts].map(([k, n]) => (n > 1 ? `${k}×${n}` : k)).join(', ')}`)
471 }
472 if (agents) parts.push(`${agents} agent${agents === 1 ? '' : 's'}`)
473 try {
474 const pct = (await $.session.usage()).context.percent
475 if (typeof pct === 'number') parts.push(`ctx ${Math.round(pct)}%`)
476 } catch {
477 // usage unavailable
478 }
479 void poll($)
480 $.ui.log(parts.join(' · '))
481 return r
482 })
483
484 // 3. Ship band
485 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
486 const below = await next(e)
487 if (e.props.hasSurvey) return below
488 const now = await $.clock.now()
489 const shown = prs.filter(p => !p.hidden && (!p.doneAt || now - p.doneAt < SHOW_DONE_MS))
490 if (shown.length === 0) return below
491
492 const { Box, Text, Button } = $.ui.resolve(e)
493 const cols = e.props.bodyColumns ?? 80
494 const step = (s: Stage, label: string) => (
495 <Text color={COLOR[s]}>
496 {MARK[s]} {label}
497 </Text>
498 )
499 return (
500 <Box flexDirection="column">
501 {shown.map(p => {
502 const build =
503 p.deploy === 'missing'
504 ? 'no prod deploy!'
505 : p.state === 'MERGED' && p.deploy !== 'success' && p.deploy !== 'failure' && p.mergedAt
506 ? `prod build ${mins(now - p.mergedAt)}`
507 : 'prod build'
508 return (
509 <Box key={`${p.repo}#${p.number}`} gap={1}>
510 <Text bold>#{p.number}</Text>
511 {cols > 100 && <Text dimColor>{short(p.title || p.repo, Math.max(12, cols - 90))}</Text>}
512 {step(stageCi(p), 'CI')}
513 <Text dimColor>→</Text>
514 {step(stageMerge(p), p.state === 'CLOSED' ? 'closed' : 'merged')}
515 <Text dimColor>→</Text>
516 {p.kind === 'ios' ? step(stageTf(p), tfLabel(p, now)) : step(stageBuild(p), build)}
517 <Text dimColor>→</Text>
518 {p.kind === 'ios' ? step(stageStore(p), storeLabel(p)) : step(stageLive(p), 'live')}
519 <Button
520 key={`hide-${p.number}`}
521 label="×"
522 plain
523 dimColor
524 onPress={async () => {
525 p.hidden = true
526 await persist($)
527 $.ui.invalidate('ui.render')
528 }}
529 />
530 </Box>
531 )
532 })}
533 {below}
534 </Box>
535 )
536 })
537}
538