Run a pool of parallel Claude Code sessions, one task each: a queue any session can delegate into, at most N running at once, the next task launched the moment…

A small helper for running a few Claude Code sessions side by side, one task each.

You queue the work; fleet starts the next task when a session frees up, and keeps a board of what each one is doing and what is waiting on you.
fleet enqueue, and it waits its turn in the queue.fleet config --max <n>, per scope if you like./fleet-board pane show PRs that are ready, red or in conflict, and any open questions. A small toast appears when something changes.You can, and for two or three tasks that works well. Past that, you tend to become the bottleneck: noticing which session finished, remembering which one had which issue, choosing what comes next, and checking each PR for its status. Fleet takes care of that bookkeeping and lets you know when a person is actually needed.
Fleet is ephemeral software. It was written for one person's day-to-day work and changes as that work changes. Commands, file formats and the board may shift between versions without a migration path, and parts of it may be retired once Claude Code does the same job natively. If you depend on it, pin a version and read the release notes before updating.
claude plugin marketplace add zAcherttp/claude-fleet
claude plugin install fleet@claude-fleet
Then ask Claude to "fan these issues out in parallel", or open the board with /fleet-board.
Works on macOS, Linux and Windows. Needs Node 18+, git and the GitHub CLI (gh) for PR status.
slots 4/5
NAME TASK KEY STATE BRANCH PR NET FILES OVERLAP
alder t001 acme/api#1650 mergeable claude/alder-3f1 #1731 −1 6 —
birch t002 acme/api#1652 working claude/birch-9c2 — +2 3 src/routes/sessions.ts ← cedar
cedar t003 acme/api#1655 working claude/cedar-11a — — 2 src/routes/sessions.ts ← birch
dogw t004 acme/web#88 question claude/dogw-7d0 — — 1 —
queue (2)
1. t006 acme/api#1661 Fix the export timeout
2. t007 acme/api#1664 Retry the webhook on 502
bin/fleet keeps one JSON file per task in ~/.fleet, written atomically under a lock that recovers from a crashed holder.skills/fleet teaches Claude when to enqueue, launch, join, report state and clean up after a merge.hooks/register.tsx is a Claude Code mod that draws the board. It reads the board and one batched GraphQL query for every PR (about a second), and shows "no access" for repos your gh account can't read.waiting → queued → launching → working → question | mergeable → verifying → done. Only launching and working take a slot.fleet --self-test runs 53 checks on Linux, macOS and Windows in CI: the pool limit, queue order, held tasks, dependencies, locks, hand-over and six concurrent enqueues producing exactly one task. claude plugin test . runs 20 board tests on the terminal and the desktop at 36, 48 and 100 columns, including a worst-case board (FLEET_BOARD_FIXTURES=1, /fleet-board worst) and a plain message for each way a refresh can fail.
MIT licensed.
hooks/register.tsx 406 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { PrInfo, PrState, RunningTask, Slot, View } from '../types'
5import type { IconKind, PrRef, RunOutcome } from './model'
6import { worstView } from './worst'
7import { bandLine, changes, classifyPr, counts, emptyView, layout, meterSvg, meterText, parseBoard, prLabel, prRef, ago, boardFailure, iconSvg, parsePrUrl, prFailure, prShort, prQuery, stateIcon, readPrQuery, rememberTitles, titleOf, trackedPrs } from './model'
8
9const PANE = 'fleet-board'
10const POLL_MS = 60_000
11const view = atom({ plugin: 'fleet', key: 'view' } as const, emptyView)
12const isBandHidden = atom({ plugin: 'fleet', key: 'isBandHidden' } as const, false)
13const expanded = atom({ plugin: 'fleet', key: 'expanded' } as const, [] as string[])
14const scopeFilter = atom({ plugin: 'fleet', key: 'scopeFilter' } as const, 'all')
15const fixture = atom({ plugin: 'fleet', key: 'fixture' } as const, 'live')
16const openSections = atom({ plugin: 'fleet', key: 'openSections' } as const, [] as string[])
17
18const prTone: Record<PrState, 'success' | 'error' | 'warning' | 'merged' | 'subtle'> = {
19 ready: 'success',
20 red: 'error',
21 conflict: 'warning',
22 running: 'subtle',
23 merged: 'merged',
24 closed: 'subtle',
25 blocked: 'warning',
26 unknown: 'subtle',
27 skipped: 'subtle',
28}
29
30const COL = { id: 7, state: 12, link: 16, pr: 16 } as const
31const QUEUE_SHOWN = 5
32const LINK_TAB = 18
33
34const shortLabel: Record<PrState, string> = { ready: 'ready', red: 'red', conflict: 'conflict', running: 'CI running', merged: 'merged', closed: 'closed', blocked: 'blocked', unknown: 'checking', skipped: 'no access' }
35
36const centre = (label: string, width: number) => {
37 const room = Math.max(0, width - label.length)
38 const left = Math.floor(room / 2)
39 return ' '.repeat(left) + label + ' '.repeat(room - left)
40}
41
42const stateTone = (state: string) =>
43 state === 'question' ? 'warning' : state === 'mergeable' ? 'success' : state === 'launching' ? 'claude' : 'subtle'
44
45let busy = false
46let again = false
47
48const QUIET_ENV = { GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1', NO_COLOR: '1' }
49
50async function run($: EngineInterface, argv: string[]): Promise<RunOutcome> {
51 try {
52 const r = await $.process.run(argv, { env: QUIET_ENV, timeoutMs: 10_000 })
53 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
54 } catch (err) {
55 return { error: err instanceof Error ? err.message : String(err) }
56 }
57}
58
59async function poll($: EngineInterface) {
60 if (busy) {
61 again = true
62 return
63 }
64 busy = true
65 await update($, view, v => ({ ...v, refreshing: true }))
66 try {
67 const started = await $.clock.now()
68 const boardRun = await run($, ['node', `${$.plugin.root}/lib/fleet.mjs`, 'board', '--all', '--json'])
69 const boardError = boardFailure(boardRun)
70 if (boardError !== null || 'error' in boardRun) {
71 await update($, view, v => ({ ...v, error: boardError ?? 'fleet board failed', refreshing: false }))
72 return
73 }
74 const board = parseBoard(boardRun.stdout)
75 const boardDone = await $.clock.now()
76 const before: View = await read($, view)
77 const refs = trackedPrs(board).map(parsePrUrl).filter((r): r is PrRef => r !== null)
78 const prs: Record<string, PrInfo> = {}
79 let prError: string | null = null
80 if (refs.length > 0) {
81 const prRun = await run($, ['gh', 'api', 'graphql', '-f', `query=${prQuery(refs)}`])
82 const answer = 'error' in prRun ? { ok: false, prs: {} } : readPrQuery(refs, prRun.stdout)
83 if (!answer.ok) prError = prFailure(prRun) ?? 'PR status unavailable.'
84 for (const r of refs) {
85 prs[r.url] = answer.prs[r.url] ?? (answer.ok ? { url: r.url, number: r.number, title: '', state: 'skipped', failing: [] } : (before.prs[r.url] ?? { url: r.url, number: r.number, title: '', state: 'unknown', failing: [] }))
86 }
87 }
88 const ended = await $.clock.now()
89 const after: View = {
90 board,
91 prs,
92 titles: rememberTitles(board, before.titles ?? {}),
93 updatedAt: ended,
94 error: null,
95 refreshing: false,
96 prError,
97 timings: { boardMs: boardDone - started, prsMs: ended - boardDone, totalMs: ended - started, prCount: refs.length },
98 }
99 await update($, view, () => after)
100 for (const line of changes(before, after)) $.ui.toast(line)
101 } catch (err) {
102 const message = err instanceof Error ? err.message : String(err)
103 await update($, view, v => ({ ...v, error: `The board hit an unexpected error: ${message}`, refreshing: false }))
104 } finally {
105 busy = false
106 if (again) {
107 again = false
108 void poll($)
109 }
110 }
111}
112
113const flip = (ids: string[], id: string) => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
114
115let savedColumns: number | null = null
116
117async function openPane($: EngineInterface) {
118 const columns = await $.store.get('paneColumns')
119 return $.ui.open({ id: PANE, title: 'Fleet', ...(typeof columns === 'number' && columns >= 30 ? { columns } : {}) })
120}
121
122async function rememberWidth($: EngineInterface, placement: string, columns: number) {
123 if (placement !== 'dock' || columns < 30 || columns === savedColumns) return
124 savedColumns = columns
125 await $.store.set('paneColumns', columns)
126}
127
128async function shown($: EngineInterface): Promise<View> {
129 return (await read($, fixture)) === 'worst' ? worstView(await $.clock.now()) : read($, view)
130}
131
132async function toggleRow($: EngineInterface, id: string) {
133 await update($, expanded, ids => flip(ids, id))
134}
135
136async function toggleSection($: EngineInterface, id: string) {
137 await update($, openSections, ids => flip(ids, id))
138}
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 await $.command.register({ name: 'fleet-board', description: 'Open the live fleet board (slots, tasks, PR readiness, queue)' })
143 $.clock.every(POLL_MS, () => void poll($))
144 void poll($)
145 return next(e)
146 })
147
148 on('command.run', { command: 'fleet-board' }, async ($, e) => {
149 const arg = e.args.trim()
150 if ((arg === 'worst' || arg === 'live') && (await $.env.get('FLEET_BOARD_FIXTURES')) === '1') {
151 await update($, fixture, () => arg)
152 await openPane($)
153 return { text: arg === 'worst' ? 'Fleet board: showing the worst-case fixture. /fleet-board live to go back.' : 'Fleet board: showing live data.' }
154 }
155 await update($, isBandHidden, () => false)
156 await poll($)
157 await openPane($)
158 return { text: 'Fleet board opened.' }
159 })
160
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
163 const current = await shown($)
164 const { Box, Button, Text } = $.ui.resolve(e)
165 const c = counts(current)
166 return (
167 <Box gap={1}>
168 <Text color={c.red || c.conflict ? 'warning' : c.ready ? 'success' : 'subtle'} wrap="truncate-end">{bandLine(current)}</Text>
169 <Button key="open" plain onPress={() => void openPane($)}>board</Button>
170 <Button key="hide" plain onPress={() => void update($, isBandHidden, () => true)}>hide</Button>
171 </Box>
172 )
173 })
174
175 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176 void rememberWidth($, e.props.placement, e.props.bodyColumns)
177 const current = await shown($)
178 const { Box, Text, Link, Button } = $.ui.resolve(e)
179 if (current.board === null) {
180 return current.error ? <Text color="error" wrap="wrap">{current.error}</Text> : <Text dimColor>Reading the board…</Text>
181 }
182 const filter = await read($, scopeFilter)
183 const open = await read($, openSections)
184 const rowsOpen = await read($, expanded)
185 const plan = layout(current, filter)
186 const age = ago((await $.clock.now()) - current.updatedAt)
187 const idWidth = Math.max(COL.id, 3 + Math.max(0, ...(current.board?.running ?? []).map(t => t.id.length), ...(current.board?.queue ?? []).map(q => q.id.length)))
188 const c = counts(current)
189 const wide = (e.props.bodyColumns ?? 80) >= 72
190 const compact = (e.props.bodyColumns ?? 80) < 44
191
192 let picker: RenderChildren = null
193 if (e.surface !== 'mobile') {
194 const { Select } = $.ui.resolve(e)
195 picker = <Select key="scope" label="Show" options={plan.scopes.map(s => ({ value: s, label: s === 'all' ? 'all groups' : s }))} value={filter} onSelect={(value: string) => void update($, scopeFilter, () => value)} />
196 }
197 let meter = (slot: Slot) => <Text color={slot.held ? 'warning' : 'success'}>{meterText(slot)}</Text>
198 let icon = (_kind: IconKind): RenderChildren => null
199 if (e.surface === 'desktop') {
200 const { Svg } = $.ui.resolve(e)
201 icon = (kind: IconKind) => <Box width={2} flexShrink={0} alignSelf="flex-start"><Svg source={iconSvg(kind)} alt={kind} width={14} height={14} /></Box>
202 meter = (slot: Slot) => <Svg source={meterSvg(slot)} alt={`${slot.used} of ${slot.max} slots in use${slot.held ? ', held' : ''}`} height={10} />
203 }
204
205 const badge = (label: string, tone: string, width?: number, key?: string) => (
206 <Box key={key} width={width} flexShrink={0} backgroundColor={tone} paddingX={1} flexDirection="column" justifyContent="center" alignItems="center">
207 <Text color="inverseText" bold wrap="truncate-end">{label}</Text>
208 </Box>
209 )
210 const indent = idWidth + 1 + (e.surface === 'desktop' ? 3 : 0) + COL.state + 1
211 const cell = (width: number, child: RenderChildren, key?: string) => (
212 <Box key={key} width={width} flexShrink={0} alignSelf="flex-start">{child}</Box>
213 )
214 const prCells = (url: string | null, pr: PrInfo | undefined) =>
215 wide
216 ? [
217 cell(COL.link, url ? <Link href={url}>{prShort(url)}</Link> : <Text> </Text>, 'link'),
218 url ? icon(pr?.state ?? 'unknown') : cell(2, <Text> </Text>, 'pr-icon'),
219 url ? badge(prLabel[pr?.state ?? 'unknown'], prTone[pr?.state ?? 'unknown'], COL.pr, 'pr') : cell(COL.pr, <Text> </Text>, 'pr'),
220 ]
221 : []
222
223 const taskRow = (task: RunningTask) => {
224 const isOpen = rowsOpen.includes(task.id)
225 const pr = task.pr ? current.prs[task.pr] : undefined
226 return (
227 <Box key={`row-${task.id}`} flexDirection="column" hover={{ scope: task.id, backgroundColor: 'subtle' }}>
228 <Box gap={1}>
229 {cell(idWidth, <Button key={`task-${task.id}`} label={`${isOpen ? '▾' : '▸'} ${task.id}`} plain onPress={() => void toggleRow($, task.id)} />)}
230 {icon(stateIcon(task.state))}
231 {badge(task.state, stateTone(task.state), COL.state)}
232 {!compact && <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={8}>
233 <Text wrap="truncate-end">{titleOf(task, current.titles)}</Text>
234 {!wide && task.pr && (
235 <Box gap={1}>
236 <Box width={LINK_TAB} flexShrink={1} minWidth={6} overflow="hidden"><Link href={task.pr}>{prShort(task.pr)}</Link></Box>
237 <Box flexGrow={1} />
238 {icon(pr?.state ?? 'unknown')}
239 {badge(prLabel[pr?.state ?? 'unknown'], prTone[pr?.state ?? 'unknown'])}
240 <Box width={1} flexShrink={0} />
241 </Box>
242 )}
243 </Box>}
244 {prCells(task.pr, pr)}
245 </Box>
246 {isOpen && (
247 <Box flexDirection="column" paddingLeft={compact ? 2 : indent}>
248 {compact && <Text wrap="wrap">{titleOf(task, current.titles)}</Text>}
249 {compact && task.pr && <Text><Link href={task.pr}>{prShort(task.pr)}</Link> <Text color={prTone[pr?.state ?? 'unknown']}>{prLabel[pr?.state ?? 'unknown']}</Text></Text>}
250 <Text dimColor wrap="truncate-end">{task.key}</Text>
251 {task.note && <Text wrap="wrap">{task.note}</Text>}
252 {pr && pr.failing.length > 0 && <Text color="error" wrap="wrap">failing: {pr.failing.join(', ')}</Text>}
253 </Box>
254 )}
255 </Box>
256 )
257 }
258
259 const header = (label: string, count?: number) => (
260 <Box gap={1}>
261 <Text bold>{label}</Text>
262 {count !== undefined && <Text dimColor>{count}</Text>}
263 </Box>
264 )
265
266 const ready = plan.attention.filter(a => a.pr?.state === 'ready').length
267 const red = plan.attention.filter(a => a.pr?.state === 'red').length
268 const conflict = plan.attention.filter(a => a.pr?.state === 'conflict').length
269 const questions = plan.attention.filter(a => a.note !== null).length
270
271 return (
272 <Box flexDirection="column" gap={1}>
273 <Box flexDirection="column" gap={1}>
274 <Box gap={1} flexWrap="wrap" alignItems="center">
275 <Button key="refresh" plain onPress={() => void poll($)}>{current.refreshing ? 'Refreshing…' : 'Refresh'}</Button>
276 <Text dimColor>updated {age}</Text>
277 {current.timings && current.timings.totalMs >= 5000 && (
278 <Text color="warning" hover={{ scope: 'slow-refresh', underline: true }}>· took {(current.timings.totalMs / 1000).toFixed(0)}s</Text>
279 )}
280 {picker}
281 </Box>
282 {current.error && <Text color="error" wrap="wrap">Not refreshed: {current.error}</Text>}
283 {current.prError && <Text color="warning" wrap="wrap">{current.prError}</Text>}
284 <Box gap={1} flexWrap="wrap">
285 {badge(`${c.used}/${c.max} running`, c.used > c.max ? 'warning' : c.used === c.max ? 'subtle' : 'success', undefined, 's')}
286 {ready > 0 && badge(`${ready} ready`, 'success', undefined, 'r')}
287 {red > 0 && badge(`${red} red`, 'error', undefined, 'x')}
288 {conflict > 0 && badge(`${conflict} conflict`, 'warning', undefined, 'c')}
289 {questions > 0 && badge(`${questions} question${questions === 1 ? '' : 's'}`, 'warning', undefined, 'q')}
290 </Box>
291 </Box>
292
293 <Box flexDirection="column" borderStyle="round" borderColor={plan.attention.length ? 'warning' : 'subtle'} paddingX={1}>
294 {header('Needs you', plan.attention.length)}
295 {plan.attention.length === 0 && <Text dimColor>Nothing waiting on you.</Text>}
296 {plan.attention.map(item => (
297 <Box key={`att-${item.id}`} gap={1} hover={{ scope: item.id, backgroundColor: 'subtle' }}>
298 {cell(idWidth, <Text bold>{item.id}</Text>)}
299 {icon(item.pr ? item.pr.state : 'question')}
300 {item.pr ? badge(shortLabel[item.pr.state], prTone[item.pr.state], COL.state) : badge('question', 'warning', COL.state)}
301 {!compact && <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={8}>
302 <Text wrap="truncate-end">{item.title}</Text>
303 {item.pr && !wide && (
304 <Box gap={1}>
305 <Box width={LINK_TAB} flexShrink={1} minWidth={6} overflow="hidden"><Link href={item.pr.url}>{prShort(item.pr.url)}</Link></Box>
306 <Box flexGrow={1} />
307 {item.scope !== 'default' && <Text dimColor>{item.scope}</Text>}
308 <Box width={1} flexShrink={0} />
309 </Box>
310 )}
311 {!item.pr && !wide && item.scope !== 'default' && <Text dimColor>{item.scope}</Text>}
312 {item.pr && item.pr.failing.length > 0 && (
313 <Text color="error" wrap="truncate-end">failing: {item.pr.failing.slice(0, 3).join(', ')}{item.pr.failing.length > 3 ? ` +${item.pr.failing.length - 3}` : ''}</Text>
314 )}
315 {item.note !== null && (
316 <Box gap={1}>
317 {item.pr && <Text color="warning">question:</Text>}
318 <Box flexGrow={1} flexShrink={1} minWidth={8}><Text dimColor wrap="truncate-end">{item.note.replace(/\s+/g, ' ')}</Text></Box>
319 </Box>
320 )}
321 </Box>}
322 {wide && cell(COL.link, item.pr ? <Link href={item.pr.url}>{prShort(item.pr.url)}</Link> : <Text dimColor wrap="truncate-end">{item.scope !== 'default' ? item.scope : ''}</Text>)}
323 </Box>
324 ))}
325 </Box>
326
327 {plan.cards.map(card => (
328 <Box key={`card-${card.slot.scope}`} flexDirection="column" borderStyle="round" borderColor={card.slot.held ? 'warning' : 'subtle'} paddingX={1}>
329 <Box gap={1}>
330 <Text bold>{card.slot.scope}</Text>
331 {meter(card.slot)}
332 <Text dimColor>{card.slot.used}/{card.slot.max}</Text>
333 {card.slot.held && icon('held')}
334 {card.slot.held && badge('held', 'warning', 6)}
335 </Box>
336 {card.slot.held && card.slot.holdNote && (
337 <Box gap={1}>
338 <Button key={`hold-${card.slot.scope}`} label={open.includes(`hold-${card.slot.scope}`) ? '▾' : '▸'} plain onPress={() => void toggleSection($, `hold-${card.slot.scope}`)} />
339 <Box flexGrow={1} flexShrink={1} minWidth={8}>
340 <Text color="warning" wrap={open.includes(`hold-${card.slot.scope}`) ? 'wrap' : 'truncate-end'}>{card.slot.holdNote}</Text>
341 </Box>
342 </Box>
343 )}
344 {card.running.length === 0 && card.queue.length === 0 && <Text dimColor>Empty.</Text>}
345 {card.running.map(taskRow)}
346 {card.queue.length > 0 && (
347 <Box flexDirection="column" marginTop={1}>
348 <Text dimColor bold>Up next</Text>
349 {(open.includes(`queue-${card.slot.scope}`) ? card.queue : card.queue.slice(0, QUEUE_SHOWN)).map(q => (
350 <Box key={`q-${q.id}`} gap={1}>
351 {cell(idWidth, <Text dimColor>{q.position}. {q.id}</Text>)}
352 {icon('queued')}
353 {badge('queued', 'subtle', COL.state)}
354 {!compact && <Box flexGrow={1} flexShrink={1} minWidth={8}><Text wrap="truncate-end">{q.title}</Text></Box>}
355 </Box>
356 ))}
357 {card.queue.length > QUEUE_SHOWN && (
358 <Button key={`fold-queue-${card.slot.scope}`} label={open.includes(`queue-${card.slot.scope}`) ? 'Show fewer' : `+${card.queue.length - QUEUE_SHOWN} more`} plain onPress={() => void toggleSection($, `queue-${card.slot.scope}`)} />
359 )}
360 </Box>
361 )}
362 </Box>
363 ))}
364
365 {(plan.waiting.length > 0 || plan.verifying.length > 0) && (
366 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
367 {plan.waiting.length > 0 && (
368 <Button key="fold-waiting" label={`${open.includes('waiting') ? '▾' : '▸'} Waiting on another task or PR · ${plan.waiting.length}`} plain onPress={() => void toggleSection($, 'waiting')} />
369 )}
370 {open.includes('waiting') && plan.waiting.map(w => (
371 <Box key={`w-${w.id}`} gap={1}>
372 {cell(idWidth, <Text>{w.id}</Text>)}
373 {icon('running')}
374 {badge('waiting', 'subtle', COL.state)}
375 {!compact && (
376 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={8}>
377 <Text wrap="truncate-end">{w.title}</Text>
378 <Text dimColor wrap="truncate-end">after {w.after.split(', ').map(ref => ref.replace(/^[^/]+\//, '')).join(', ')}</Text>
379 </Box>
380 )}
381 </Box>
382 ))}
383 {plan.verifying.length > 0 && (
384 <Button key="fold-verifying" label={`${open.includes('verifying') ? '▾' : '▸'} Verifying after merge · ${plan.verifying.length}`} plain onPress={() => void toggleSection($, 'verifying')} />
385 )}
386 {open.includes('verifying') && plan.verifying.map(v => (
387 <Box key={`v-${v.id}`} gap={1}>
388 {cell(idWidth, <Text>{v.id}</Text>)}
389 {icon('merged')}
390 {badge('verifying', 'merged', COL.state)}
391 {!compact && (
392 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={8}>
393 <Text wrap="truncate-end">{v.key.replace(/^[^/]+\//, '')}</Text>
394 {v.note && <Text dimColor wrap="truncate-end">{v.note}</Text>}
395 </Box>
396 )}
397 </Box>
398 ))}
399 </Box>
400 )}
401 {c.net && <Text dimColor wrap="wrap">Net issues today: {c.net}</Text>}
402 </Box>
403 )
404 })
405}
406hooks/model.ts 339 lines1import type { Board, PrInfo, PrState, QueuedTask, RunningTask, Slot, VerifyingTask, View, WaitingTask } from '../types'
2
3type Raw = Record<string, any>
4
5export const emptyView: View = { board: null, prs: {}, titles: {}, updatedAt: 0, error: null }
6
7const text = (value: unknown): string | null => (typeof value === 'string' && value.length > 0 ? value : null)
8
9export function parseBoard(json: string): Board {
10 const raw: Raw = JSON.parse(json)
11 const list = (value: unknown): Raw[] => (Array.isArray(value) ? value : [])
12 return {
13 slots: list(raw.slots).map(s => ({ scope: String(s.scope), used: Number(s.used ?? 0), max: Number(s.max ?? 0), held: Boolean(s.held), holdNote: text(s.held?.note) })),
14 running: list(raw.running).map(t => ({
15 id: String(t.id),
16 scope: String(t.scope ?? 'default'),
17 key: String(t.key ?? ''),
18 label: String(t.label ?? t.id),
19 state: String(t.state ?? ''),
20 pr: text(t.pr),
21 note: text(t.note),
22 })),
23 queue: list(raw.queue).map(t => ({ id: String(t.id), scope: String(t.scope ?? 'default'), key: String(t.key ?? ''), title: String(t.title ?? ''), position: Number(t.position ?? 0) })),
24 waiting: list(raw.waiting).map(t => ({ id: String(t.id), key: String(t.key ?? ''), title: String(t.title ?? ''), after: list(t.after).map(String).join(', ') })),
25 verifying: list(raw.verifying).map(t => ({ id: String(t.id), key: String(t.key ?? ''), pr: text(t.pr), note: text(t.note) })),
26 net: list(raw.net).map(n => ({ project: String(n.project), net: Number(n.net ?? 0), filed: list(n.filed).length, closed: list(n.closed).length })),
27 }
28}
29
30const isRed = (c: Raw) => ['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(c.conclusion) || ['FAILURE', 'ERROR'].includes(c.state)
31const isPending = (c: Raw) => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING' || c.state === 'EXPECTED'
32
33export function classifyPr(url: string, json: string): PrInfo {
34 return classifyRaw(url, JSON.parse(json))
35}
36
37export function classifyRaw(url: string, raw: Raw): PrInfo {
38 const checks: Raw[] = Array.isArray(raw.statusCheckRollup) ? raw.statusCheckRollup : []
39 const failing = checks.filter(isRed).map(c => String(c.name ?? c.context ?? 'check'))
40 const pending = checks.some(isPending)
41 const merge = String(raw.mergeStateStatus ?? 'UNKNOWN')
42 const state: PrState =
43 raw.state === 'MERGED' ? 'merged'
44 : raw.state === 'CLOSED' ? 'closed'
45 : merge === 'DIRTY' ? 'conflict'
46 : failing.length > 0 && !pending ? 'red'
47 : pending ? 'running'
48 : ['CLEAN', 'HAS_HOOKS', 'UNSTABLE'].includes(merge) ? 'ready'
49 : merge === 'BLOCKED' ? 'blocked'
50 : 'unknown'
51 return { url, number: Number(raw.number ?? prNumber(url)), title: String(raw.title ?? ''), state, failing }
52}
53
54export function prNumber(url: string): number {
55 const match = /\/pull\/(\d+)/.exec(url)
56 return match ? Number(match[1]) : 0
57}
58
59export function prRef(url: string): string {
60 const match = /github\.com\/[^/]+\/([^/]+)\/pull\/(\d+)/.exec(url)
61 return match ? `${match[1]}#${match[2]}` : url
62}
63
64export function trackedPrs(board: Board): string[] {
65 return [...new Set(board.running.map(t => t.pr).filter((pr): pr is string => pr !== null && pr.includes('/pull/')))]
66}
67
68export const prLabel: Record<PrState, string> = {
69 ready: 'ready to merge',
70 red: 'red',
71 conflict: 'conflict',
72 running: 'CI running',
73 merged: 'merged',
74 closed: 'closed',
75 blocked: 'blocked',
76 unknown: 'checking',
77 skipped: 'no access',
78}
79
80export type Counts = { used: number; max: number; ready: number; red: number; conflict: number; queued: number; waiting: number; questions: number; net: string }
81
82export function counts(view: View): Counts {
83 const board = view.board
84 const prs = Object.values(view.prs)
85 const main = board?.slots.find(s => s.scope === 'default') ?? board?.slots[0]
86 return {
87 used: main?.used ?? 0,
88 max: main?.max ?? 0,
89 ready: prs.filter(p => p.state === 'ready').length,
90 red: prs.filter(p => p.state === 'red').length,
91 conflict: prs.filter(p => p.state === 'conflict').length,
92 queued: board?.queue.length ?? 0,
93 waiting: board?.waiting.length ?? 0,
94 questions: board?.running.filter(t => t.state === 'question').length ?? 0,
95 net: (board?.net ?? []).map(n => `${n.project} ${n.net > 0 ? '+' : ''}${n.net}`).join(', '),
96 }
97}
98
99export function bandLine(view: View): string {
100 if (view.board === null) return view.error ? `fleet: ${view.error}` : 'fleet: reading the board…'
101 const c = counts(view)
102 const parts = [`fleet ${c.used}/${c.max}`]
103 if (c.ready) parts.push(`${c.ready} ready to merge`)
104 if (c.red) parts.push(`${c.red} red`)
105 if (c.conflict) parts.push(`${c.conflict} conflict`)
106 if (c.questions) parts.push(`${c.questions} question${c.questions === 1 ? '' : 's'}`)
107 parts.push(`queue ${c.queued}`)
108 if (c.waiting) parts.push(`waiting ${c.waiting}`)
109 if (c.net) parts.push(`net today ${c.net}`)
110 return parts.join(' · ')
111}
112
113export function changes(before: View, after: View): string[] {
114 if (before.board === null || after.board === null) return []
115 const out: string[] = []
116 for (const [url, pr] of Object.entries(after.prs)) {
117 const was = before.prs[url]?.state
118 if (was === undefined || was === pr.state) continue
119 if (pr.state === 'ready') out.push(`${prRef(url)} is green and ready to merge`)
120 if (pr.state === 'red') out.push(`${prRef(url)} went red: ${pr.failing.slice(0, 3).join(', ')}`)
121 if (pr.state === 'conflict') out.push(`${prRef(url)} conflicts with main`)
122 if (pr.state === 'merged') out.push(`${prRef(url)} merged`)
123 }
124 const old = new Map(before.board.running.map(t => [t.id, t.state]))
125 for (const task of after.board.running) {
126 const was = old.get(task.id)
127 if (was === undefined && task.state === 'launching') out.push(`${task.id} ready to launch: ${task.label}`)
128 else if (was !== undefined && was !== task.state && task.state === 'question') out.push(`${task.id} has a question: ${task.note ?? task.label}`)
129 }
130 const still = new Set(after.board.running.map(t => t.id))
131 for (const task of before.board.running) {
132 if (!still.has(task.id) && after.board.verifying.every(v => v.id !== task.id)) out.push(`${task.id} left the pool: ${task.label}`)
133 }
134 return out
135}
136
137export function rememberTitles(board: Board, known: Record<string, string>): Record<string, string> {
138 const titles = { ...known }
139 for (const q of board.queue) titles[q.id] = q.title
140 for (const w of board.waiting) titles[w.id] = w.title
141 for (const t of board.running) if (t.label !== t.id) titles[t.id] = t.label
142 return titles
143}
144
145export function titleOf(task: RunningTask, titles: Record<string, string>): string {
146 return task.label !== task.id ? task.label : (titles[task.id] ?? (task.key ? task.key.replace(/^[^/]+\//, '') : task.id))
147}
148
149export type Attention = { id: string; scope: string; title: string; pr: PrInfo | null; note: string | null }
150
151export type ScopeCard = { slot: Slot; running: RunningTask[]; queue: QueuedTask[] }
152
153export type Layout = {
154 attention: Attention[]
155 cards: ScopeCard[]
156 waiting: WaitingTask[]
157 verifying: VerifyingTask[]
158 scopes: string[]
159}
160
161const attentionRank = { conflict: 0, red: 1, ready: 2 } as const
162
163export function layout(view: View, filter: string): Layout {
164 const board = view.board
165 if (board === null) return { attention: [], cards: [], waiting: [], verifying: [], scopes: [] }
166 const shown = (scope: string) => filter === 'all' || filter === scope
167 const attention: Attention[] = []
168 for (const task of board.running) {
169 if (!shown(task.scope)) continue
170 const found = task.pr ? view.prs[task.pr] : undefined
171 const pr = found && (found.state === 'ready' || found.state === 'red' || found.state === 'conflict') ? found : null
172 const note = task.state === 'question' ? (task.note ?? '') : null
173 if (pr || note !== null) attention.push({ id: task.id, scope: task.scope, title: titleOf(task, view.titles), pr, note })
174 }
175 const rank = (a: Attention) => (a.pr ? attentionRank[a.pr.state as keyof typeof attentionRank] : 3)
176 attention.sort((a, b) => rank(a) - rank(b))
177 const cards = board.slots
178 .filter(slot => shown(slot.scope))
179 .map(slot => ({
180 slot,
181 running: board.running.filter(t => t.scope === slot.scope),
182 queue: board.queue.filter(q => q.scope === slot.scope).sort((a, b) => a.position - b.position),
183 }))
184 return {
185 attention,
186 cards,
187 waiting: filter === 'all' || filter === 'default' ? board.waiting : [],
188 verifying: filter === 'all' || filter === 'default' ? board.verifying : [],
189 scopes: ['all', ...board.slots.map(s => s.scope)],
190 }
191}
192
193export function meterSvg(slot: Slot): string {
194 const cell = 14
195 const gap = 4
196 const count = Math.max(slot.max, slot.used, 1)
197 const width = count * cell + (count - 1) * gap
198 const used = slot.held ? '#d29922' : slot.used >= slot.max ? '#8b949e' : '#3fb950'
199 const cells = Array.from({ length: count }, (_, i) => {
200 const x = i * (cell + gap)
201 const over = i >= slot.max
202 const filled = i < slot.used
203 const colour = over ? '#f0883e' : used
204 const fill = filled ? colour : 'none'
205 const stroke = filled ? colour : '#8b949e'
206 const dash = (slot.held && !filled) || over ? ' stroke-dasharray="3 2"' : ''
207 return `<rect x="${x + 0.5}" y="0.5" width="${cell - 1}" height="9" rx="3" fill="${fill}" stroke="${stroke}" stroke-opacity="0.8"${dash}/>`
208 })
209 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="10" viewBox="0 0 ${width} 10">${cells.join('')}</svg>`
210}
211
212export function meterText(slot: Slot): string {
213 const inside = Math.min(slot.used, slot.max)
214 return '\u25a0'.repeat(inside) + '\u25a1'.repeat(Math.max(0, slot.max - slot.used)) + (slot.used > slot.max ? ' +' + (slot.used - slot.max) : '')
215}
216
217export function prShort(url: string): string {
218 const match = /github\.com\/[^/]+\/([^/]+)\/pull\/(\d+)/.exec(url)
219 return match ? `${match[1]}#${match[2]}` : url
220}
221
222export function ago(ms: number): string {
223 const s = Math.max(0, Math.round(ms / 1000))
224 if (s < 60) return `${s}s ago`
225 if (s < 3600) return `${Math.round(s / 60)} min ago`
226 if (s < 86400) return `${Math.round(s / 3600)} h ago`
227 return `${Math.round(s / 86400)} d ago`
228}
229
230export type PrRef = { url: string; owner: string; repo: string; number: number }
231
232export function parsePrUrl(url: string): PrRef | null {
233 const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(url)
234 return match ? { url, owner: match[1]!, repo: match[2]!, number: Number(match[3]) } : null
235}
236
237const PR_FIELDS = 'number title state mergeStateStatus commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } }'
238
239export function prQuery(refs: readonly PrRef[]): string {
240 const repos = new Map<string, PrRef[]>()
241 for (const ref of refs) repos.set(`${ref.owner}/${ref.repo}`, [...(repos.get(`${ref.owner}/${ref.repo}`) ?? []), ref])
242 const blocks = [...repos.entries()].map(([, list], i) => {
243 const first = list[0]!
244 const prs = list.map(r => `pr${r.number}: pullRequest(number: ${r.number}) { ${PR_FIELDS} }`).join(' ')
245 return `r${i}: repository(owner: ${JSON.stringify(first.owner)}, name: ${JSON.stringify(first.repo)}) { ${prs} }`
246 })
247 return `query { ${blocks.join(' ')} }`
248}
249
250export function readPrQuery(refs: readonly PrRef[], json: string): { ok: boolean; prs: Record<string, PrInfo> } {
251 let parsed: Raw
252 try {
253 parsed = JSON.parse(json)
254 } catch {
255 return { ok: false, prs: {} }
256 }
257 const data: Raw | null = parsed?.data ?? null
258 if (data === null) return { ok: false, prs: {} }
259 const order = [...new Set(refs.map(r => `${r.owner}/${r.repo}`))]
260 const out: Record<string, PrInfo> = {}
261 for (const ref of refs) {
262 const node: Raw | undefined = data[`r${order.indexOf(`${ref.owner}/${ref.repo}`)}`]?.[`pr${ref.number}`]
263 if (!node) continue
264 const contexts: Raw[] = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? []
265 out[ref.url] = classifyRaw(ref.url, { number: node.number, title: node.title, state: node.state, mergeStateStatus: node.mergeStateStatus, statusCheckRollup: contexts })
266 }
267 return { ok: true, prs: out }
268}
269
270export type IconKind = 'working' | 'question' | 'mergeable' | 'launching' | 'ready' | 'red' | 'conflict' | 'running' | 'merged' | 'queued' | 'held' | 'skipped' | 'closed' | 'blocked' | 'unknown'
271
272const ICON_COLOR: Record<IconKind, string> = {
273 working: '#58a6ff', question: '#d29922', mergeable: '#3fb950', launching: '#a371f7', ready: '#3fb950', red: '#f85149', conflict: '#d29922',
274 running: '#8b949e', merged: '#a371f7', queued: '#8b949e', held: '#d29922', skipped: '#8b949e', closed: '#8b949e', blocked: '#d29922', unknown: '#8b949e',
275}
276
277const ICON_PATH: Record<IconKind, string> = {
278 working: '<path d="M12 3a9 9 0 1 0 9 9"/>',
279 question: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14"/><circle cx="12" cy="17.2" r=".6" fill="C"/>',
280 mergeable: '<circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.8 2.8L16.5 9"/>',
281 launching: '<circle cx="12" cy="12" r="9"/><path d="m10 8.5 5.5 3.5-5.5 3.5z" fill="C"/>',
282 ready: '<circle cx="12" cy="12" r="9" fill="C" fill-opacity=".18"/><path d="m8 12.5 2.8 2.8L16.5 9"/>',
283 red: '<circle cx="12" cy="12" r="9" fill="C" fill-opacity=".18"/><path d="m9 9 6 6m0-6-6 6"/>',
284 conflict: '<path d="M12 3.5 21 19.5H3z" fill="C" fill-opacity=".18"/><path d="M12 10v4"/><circle cx="12" cy="17" r=".6" fill="C"/>',
285 running: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5V12l3 2"/>',
286 merged: '<circle cx="7" cy="6" r="2"/><circle cx="7" cy="18" r="2"/><circle cx="17" cy="12" r="2"/><path d="M7 8v8M7 8c0 3 3 4 8 4"/>',
287 queued: '<circle cx="6" cy="12" r="1.3" fill="C"/><circle cx="12" cy="12" r="1.3" fill="C"/><circle cx="18" cy="12" r="1.3" fill="C"/>',
288 held: '<circle cx="12" cy="12" r="9"/><path d="M10 9v6M14 9v6"/>',
289 skipped: '<circle cx="12" cy="12" r="9"/><path d="M8.5 12h7"/>',
290 closed: '<circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/>',
291 blocked: '<circle cx="12" cy="12" r="9"/><path d="m6 18 12-12"/>',
292 unknown: '<circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/>',
293}
294
295export function iconSvg(kind: IconKind): string {
296 const color = ICON_COLOR[kind]
297 return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICON_PATH[kind].replaceAll('"C"', `"${color}"`)}</svg>`
298}
299
300export function stateIcon(state: string): IconKind {
301 return (['working', 'question', 'mergeable', 'launching', 'queued'] as const).find(k => k === state) ?? (state === 'verifying' ? 'running' : 'unknown')
302}
303
304export type RunOutcome = { exitCode: number; stdout: string; stderr: string } | { error: string }
305
306const firstLine = (text: string) => text.trim().split('\n').find(line => line.trim().length > 0)?.trim().slice(0, 200) ?? ''
307
308const isMissing = (o: RunOutcome) =>
309 'error' in o ? /ENOENT|not found|no such file|cannot find|not recognized/i.test(o.error) : o.exitCode === 127 || o.exitCode === 9009
310
311const isTimeout = (o: RunOutcome) => 'error' in o && /time ?out|timed out|ETIMEDOUT|killed/i.test(o.error)
312
313export function boardFailure(o: RunOutcome): string | null {
314 if (isMissing(o)) return "Can't run fleet: Node.js isn't on the PATH Claude Code started with. Install Node 18 or newer, or start Claude Code from a terminal where `node --version` works."
315 if (isTimeout(o)) return '`fleet board` took longer than 10 s. Another fleet command may be holding the lock; `fleet sweep` clears a stale one.'
316 if ('error' in o) return `Couldn't start \`fleet board\`: ${firstLine(o.error)}`
317 if (o.exitCode !== 0) {
318 if (/SyntaxError|Unexpected token|ERR_UNKNOWN_FILE_EXTENSION/.test(o.stderr)) return 'fleet needs Node 18 or newer; the `node` Claude Code found is older. Check `node --version`.'
319 return `\`fleet board\` failed (exit ${o.exitCode}): ${firstLine(o.stderr) || 'no error output'}`
320 }
321 try {
322 JSON.parse(o.stdout)
323 return null
324 } catch {
325 return `\`fleet board\` printed something other than JSON ("${firstLine(o.stdout).slice(0, 80)}"). Run \`fleet board --all --json\` in a terminal to see why.`
326 }
327}
328
329export function prFailure(o: RunOutcome): string | null {
330 if (isMissing(o)) return 'PR status needs the GitHub CLI: install `gh`, then run `gh auth login`.'
331 if (isTimeout(o)) return "Couldn't reach GitHub within 10 s; showing the last known PR status."
332 const text = 'error' in o ? o.error : o.stderr
333 if ('exitCode' in o && o.exitCode === 0) return null
334 if (/auth login|not logged in|authentication|HTTP 401|Bad credentials/i.test(text)) return "`gh` isn't signed in, so PR status is unavailable. Run `gh auth login`."
335 if (/rate limit/i.test(text)) return "GitHub's API rate limit was reached; PR status resumes on its own."
336 if (/could not resolve host|network|ECONN|dial tcp|i\/o timeout/i.test(text)) return "Couldn't reach GitHub; showing the last known PR status."
337 return `PR status unavailable: ${firstLine(text) || 'gh gave no reason'}`
338}
339hooks/worst.ts 73 lines1import type { View } from '../types'
2import { classifyRaw, parseBoard } from './model'
3
4const WEB = (n: number) => `https://github.com/northwind-industries/web/pull/${n}`
5const DOCS = (n: number) => `https://github.com/northwind-industries/docs-site/pull/${n}`
6const MOBILE = (n: number) => `https://github.com/northwind-industries/mobile-app/pull/${n}`
7const COURSE = (n: number) => `https://github.com/university-team-7/game/pull/${n}`
8
9const check = (name: string, conclusion: string) => ({ name, status: 'COMPLETED', conclusion })
10
11export function worstView(now: number): View {
12 const board = parseBoard(
13 JSON.stringify({
14 slots: [
15 { scope: 'default', used: 6, max: 5 },
16 { scope: 'coursework', used: 2, max: 5 },
17 { scope: 'release-freeze', used: 0, max: 2, held: { note: "Aleksandra's holiday-sale deploy on Friday: nothing starts in mobile-app until she confirms the release is out and checked", at: now } },
18 ],
19 running: [
20 { id: 't1001', scope: 'default', key: 'northwind-industries/web#3081', label: 'Translate the 13 checkout error messages into Vietnamese', state: 'question', pr: null, note: 'parked: waiting for Bartholomew Fitzgerald-Oyelaran to send the approved glossary (sha cf384d1e); wording approved, committed 81a92d7a4 locally' },
21 { id: 't1003', scope: 'default', key: 'northwind-industries/web#3090', label: 'Remove the legacy coupon, gift-card, loyalty-points and partner-voucher endpoints', state: 'question', pr: WEB(4512), note: 'web#4512 conflicts with main after web#4509; the sync tool needs the repository origin confirmed (Help → Troubleshooting → Review Pinned Git Origins) https://github.com/northwind-industries/web/pull/4512/files#diff-0b6f22c4e1a9d3b7f5e8c2a14d6b9e03' },
22 { id: 't1004', scope: 'default', key: 'northwind-industries/web#3100-legacy-cleanup', label: 't1004', state: 'launching', pr: null, note: null },
23 { id: 't1005', scope: 'default', key: 'northwind-industries/docs-site#288', label: 'Cập nhật hướng dẫn đổi trả hàng — 13 mục câu hỏi thường gặp chưa qua rà soát chính tả', state: 'mergeable', pr: DOCS(288), note: null },
24 { id: 't1006', scope: 'default', key: 'northwind-industries/web#3112', label: 'Shipping estimate shows 0 days for every international order', state: 'working', pr: WEB(4530), note: null },
25 { id: 't1007', scope: 'default', key: 'northwind-industries/mobile-app#1977', label: 'Show the saved-cards list on the order summary screen', state: 'mergeable', pr: MOBILE(1977), note: null },
26 { id: 't1101', scope: 'coursework', key: 'university-team-7/game#editor', label: 'Level editor hand-off', state: 'question', pr: null, note: 'Music paused on 2026-10-05 for clean-up; branch polish/music pushed (3ab8c74); resume steps in the TODO Music section' },
27 { id: 't1102', scope: 'coursework', key: 'university-team-7/game#19', label: 'Smoother camera springs 🌀 and a low-FPS fallback for older tablets', state: 'working', pr: COURSE(19), note: null },
28 ],
29 queue: Array.from({ length: 14 }, (_, i) => ({
30 scope: i === 13 ? 'release-freeze' : 'default',
31 position: i === 13 ? 1 : i + 1,
32 id: `t${1010 + i}`,
33 key: `northwind-industries/web#${3200 + i}`,
34 title: i === 0 ? 'Performance pass: product listing, search filters, recommendations and wishlist pages (wave 4)' : `Performance pass ${i + 1}: cart, checkout and receipts`,
35 })),
36 waiting: [
37 { id: 't1201', key: 'northwind-industries/web#sales-dashboard', title: 'Sales dashboard: weekly revenue chart', after: ['t1200'] },
38 { id: 't1202', key: 'northwind-industries/web#3150', title: 'Remove unused feature flags; fix stale cache keys', after: ['northwind-industries/web#4501', 'northwind-industries/docs-site#290'] },
39 ],
40 verifying: [{ id: 't1301', key: 'northwind-industries/web#3060', pr: WEB(4480), note: 'merged 0bb90d843; waiting for the deploy and one production order with the new tax rounding' }],
41 net: [
42 { project: 'web', filed: Array(7).fill('x'), closed: Array(3).fill('x'), net: 4 },
43 { project: 'mobile-app', filed: ['x'], closed: ['x'], net: 0 },
44 { project: 'docs-site', filed: ['x'], closed: [], net: 1 },
45 { project: 'payments-service', filed: [], closed: ['x'], net: -1 },
46 { project: 'design-system', filed: [], closed: ['x'], net: -1 },
47 ],
48 }),
49 )
50 const prs = {
51 [WEB(4512)]: classifyRaw(WEB(4512), { number: 4512, title: 't', state: 'OPEN', mergeStateStatus: 'DIRTY', statusCheckRollup: [check('ci', 'SUCCESS')] }),
52 [DOCS(288)]: classifyRaw(DOCS(288), { number: 288, title: 't', state: 'OPEN', mergeStateStatus: 'CLEAN', statusCheckRollup: [check('lint', 'SUCCESS')] }),
53 [WEB(4530)]: classifyRaw(WEB(4530), {
54 number: 4530,
55 title: 't',
56 state: 'OPEN',
57 mergeStateStatus: 'BLOCKED',
58 statusCheckRollup: [check('ci', 'FAILURE'), check('lint (blocking)', 'FAILURE'), check('e2e / checkout-flow (ubuntu-26.04, node 24)', 'FAILURE'), check('secret-scan', 'FAILURE')],
59 }),
60 [MOBILE(1977)]: classifyRaw(MOBILE(1977), { number: 1977, title: 't', state: 'OPEN', mergeStateStatus: 'CLEAN', statusCheckRollup: [] }),
61 [COURSE(19)]: { url: COURSE(19), number: 19, title: '', state: 'skipped' as const, failing: [] },
62 }
63 return {
64 board,
65 prs,
66 titles: {},
67 updatedAt: now - 2 * 60 * 60 * 1000,
68 error: null,
69 refreshing: false,
70 timings: { boardMs: 1712, prsMs: 21640, totalMs: 23352, prCount: 4 },
71 }
72}
73types/index.d.ts 35 lines1export type PrState = 'ready' | 'red' | 'conflict' | 'running' | 'merged' | 'closed' | 'blocked' | 'unknown' | 'skipped'
2
3export type PrInfo = { url: string; number: number; title: string; state: PrState; failing: string[] }
4
5export type RunningTask = { id: string; scope: string; key: string; label: string; state: string; pr: string | null; note: string | null }
6
7export type QueuedTask = { id: string; scope: string; key: string; title: string; position: number }
8
9export type WaitingTask = { id: string; key: string; title: string; after: string }
10
11export type VerifyingTask = { id: string; key: string; pr: string | null; note: string | null }
12
13export type Slot = { scope: string; used: number; max: number; held: boolean; holdNote: string | null }
14
15export type NetRow = { project: string; net: number; filed: number; closed: number }
16
17export type Board = {
18 slots: Slot[]
19 running: RunningTask[]
20 queue: QueuedTask[]
21 waiting: WaitingTask[]
22 verifying: VerifyingTask[]
23 net: NetRow[]
24}
25
26export type Timings = { boardMs: number; prsMs: number; totalMs: number; prCount: number }
27
28export type View = { board: Board | null; prs: Record<string, PrInfo>; titles: Record<string, string>; updatedAt: number; error: string | null; refreshing?: boolean; prError?: string | null; timings?: Timings }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'fleet': { view: View; isBandHidden: boolean; expanded: string[]; scopeFilter: string; openSections: string[]; fixture: string }
33 }
34}
35