A pane tracking every app the software factory built: live health, deploy status, product stage and open tickets

A Claude Code plugin that ships with the software factory. One pane, four tabs, covering every app the factory has built:
Type /factory in a Claude Code session started in the factory.

The tab buttons sit at the top of the pane. You can also jump straight to a tab:
| Type | Opens |
|---|---|
/factory products | Products & apps (the board opens here) |
/factory molds | Molds |
/factory tickets | Tickets |
/factory analytics onfinance_hfc_vm | Analytics, with that app picked |
/factory mold_v1-215 | that ticket's details |
/factory refresh | the board, after a fresh health check |

Each product in state/products.json gets:
defined → built → tested → deployed → released. Done stages are ticked and the current one is green. A product of a coming-soon mold shows coming soon instead.healthy, down, pending, or n/a for a retired app), shown only when it isn't plain stamped. Its record status shows beside it, as reverted or retired. Where it runs: vercel, or own server.RLS 62/62 means all 62 workspace tables are locked to their workspace.current is the factory's latest snapshot; behind means a redeploy would update it.built: $1,299 (shared) + ~$37.62 est. · agent 16h 55m · active 12h 39m · first message → live 21h 33m · 70 deploys:0.The build figures come from .claude/scripts/mint_report.py --all --json, which the board runs every hour and on Recompute build costs (at the bottom of this tab). It reads the agent sessions and git on this machine, goes nowhere online, and writes reports/mint/<app>.json and .md for each app that isn't retired. reports/mint/<app>.md explains each figure in full, with the share taken of each session.
Apps that belong to no product are listed under Other apps.

Each mold with its snapshot version and its tickets (open, in progress, done). A mold marked coming_soon in state/factory.json shows coming soon and its number of planned tickets. Today that's mold_v2 and mold_v3.

All, each priority, and each app that has tickets, with counts.
A ticket shows:
There are two buttons:
Work on factory ticket <id> … into your prompt, ready to send.
$0. A cost the app recorded is shown plainly. Some older helper runs recorded their tokens but no cost and no model, because Cloudflare Workers AI doesn't report costs and the app didn't yet record which model ran. Their cost is estimated: the tokens are priced at the model that helper is set up to use now, with the app's own price list. An estimate is shown dimmed as ~$1.23 est., so it never looks like a measured figure. The report names the model it used (estimated_from).The numbers come from .claude/scripts/app_usage.py, which the board runs every 30 minutes and on Collect usage. It reads each app's own database as the app's own restricted role, one workspace at a time, in read-only transactions. Only counts leave the database: no messages, no names, no email addresses. The results are kept in reports/usage/<app>.json, which is not committed.
.claude/scripts/uptime.py checks every app that isn't retired once a minute, from a systemd timer on the factory machine (uptime.py install; uptime.py status shows the timer). It loads the same health pages as the board, with a 10-second timeout and one retry, and writes .runs/uptime/state.json, which is not committed.
✕ DOWN: <app>.uptime.py set-email-key, typed by the operator). Without one, the banner is the alert. uptime.py preview-alert prints the exact email and sends nothing.uptime.py lists free outside options and their trade-offs.A one-line summary sits in the status line, so you can see it without opening the pane:

The board checks health every 5 minutes on its own.
Inside the factory there is nothing to do: .claude/settings.json turns the plugin on from the factory's own marketplace (.claude-plugin/marketplace.json). Elsewhere, type this at a Claude Code prompt, answer y to add the marketplace, then pick a scope:
/plugin install factory-board --marketplace never2average/software-factory
The board reads the factory's records from the folder Claude Code was started in when that folder is the factory, and otherwise from /root/software-factory. It never changes a record, a ticket or an app.
hooks/register.tsx holds the pane, its tabs, /factory and the timers.hooks/board.ts holds the pure readers of the state files, the usage report and the build reports.types/index.d.ts declares the values the pane keeps.The tests are:
hooks/board.test.ts for the readers, the build lines and the product totals;hooks/uptime-ui.test.tsx, which checks that the red banner and the status line show a down app, and stay away while it's up, on both surfaces;hooks/tickets-ui.test.tsx, which clicks through the tickets, the filters and the Products & apps tab (with its build lines and Recompute build costs) on the terminal and desktop surfaces against a small fake factory.claude plugin validate plugins/factory-board
claude plugin test plugins/factory-board
The screenshots in docs/ are real captures of the running board, taken on 2026-10-08 in a terminal 170 columns wide.
hooks/register.tsx 819 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AppRow, Board, Build, Check, Tab, Ticket, Uptime, Usage } from '../types'
5import {
6 STAGES,
7 STAGE_WORDS,
8 ago,
9 buildParts,
10 parseBuild,
11 sumBuilds,
12 fit,
13 plainTitle,
14 ticketMatches,
15 appRow,
16 isComingSoon,
17 isEstimate,
18 cost,
19 money,
20 moldRow,
21 openTickets,
22 parseTasks,
23 parseUptime,
24 parseUsage,
25 plus,
26 productRows,
27 short,
28 sparkline,
29 ticketsForApp,
30 uptimeBanner,
31 verdict,
32} from './board'
33
34const PANE = 'factory-board'
35const FALLBACK_ROOT = '/root/software-factory'
36const EVERY_MS = 5 * 60_000
37const USAGE_EVERY_MS = 30 * 60_000
38const PROBE_MS = 10_000
39const COLLECT_MS = 5 * 60_000
40const BUILD_EVERY_MS = 60 * 60_000
41const COMPUTE_MS = 10 * 60_000
42/** uptime.py writes its state every minute (systemd timer); the board reads it as often. */
43const UPTIME_EVERY_MS = 60_000
44
45const board = atom({ plugin: 'factory-board', key: 'board' } as const, null)
46const isRefreshing = atom({ plugin: 'factory-board', key: 'isRefreshing' } as const, false)
47const tab = atom({ plugin: 'factory-board', key: 'tab' } as const, 'products')
48const selectedApp = atom({ plugin: 'factory-board', key: 'selectedApp' } as const, '')
49const usage = atom({ plugin: 'factory-board', key: 'usage' } as const, {})
50const isCollecting = atom({ plugin: 'factory-board', key: 'isCollecting' } as const, false)
51const collectError = atom({ plugin: 'factory-board', key: 'collectError' } as const, '')
52const openTicket = atom({ plugin: 'factory-board', key: 'openTicket' } as const, '')
53const ticketFilter = atom({ plugin: 'factory-board', key: 'ticketFilter' } as const, 'all')
54const builds = atom({ plugin: 'factory-board', key: 'builds' } as const, {})
55const isComputing = atom({ plugin: 'factory-board', key: 'isComputing' } as const, false)
56const computeError = atom({ plugin: 'factory-board', key: 'computeError' } as const, '')
57const uptime = atom({ plugin: 'factory-board', key: 'uptime' } as const, null)
58
59type $ = EngineInterface
60
61let root = FALLBACK_ROOT
62
63// The "in progress" locks live in the module, not in $.state: $.state survives a reload of the plugin, so a lock held
64// there by a refresh the reload cut short would stay set and every later refresh would skip (the board then sat on
65// "Reading…"). A reload starts the module over, and these with it.
66let refreshing = false
67let collecting = false
68let computing = false
69const STORE_KEY = 'last-board'
70
71async function readJson($: $, path: string): Promise<any> {
72 return JSON.parse(await $.fs.read(path))
73}
74
75/* ---- the factory's records ------------------------------------------------------------------------------------ */
76
77/** Reads the factory's state files into a board; health probes stay pending until `probe`. */
78async function load($: $): Promise<Board> {
79 const now = await $.clock.now()
80 const factory = await readJson($, `${root}/state/factory.json`)
81 const snapshots = new Map<string, string>(
82 (factory.molds ?? []).map((m: any) => [String(m.mold_id), String(m.source?.commit ?? '')]),
83 )
84 const statuses = new Map<string, string>((factory.molds ?? []).map((m: any) => [String(m.mold_id), String(m.status ?? '')]))
85
86 const apps: AppRow[] = []
87 for (const entry of await $.fs.list(`${root}/state/application`)) {
88 if (entry.kind !== 'dir' || entry.name === 'app_id') continue
89 const dir = `${root}/state/application/${entry.name}`
90 try {
91 const [application, infrastructure, datastores] = await Promise.all([
92 readJson($, `${dir}/application.json`),
93 readJson($, `${dir}/infrastructure.json`),
94 readJson($, `${dir}/datastores.json`).catch(() => ({})),
95 ])
96 apps.push(appRow(entry.name, application, infrastructure, datastores, snapshots))
97 } catch {
98 // an app folder without its state files is not an app yet
99 }
100 }
101
102 const tickets = []
103 const molds = []
104 for (const [id, snapshot] of snapshots) {
105 const text = await $.fs.read(`${root}/state/tasks/${id}.jsonl`).catch(() => '')
106 const tasks = parseTasks(text)
107 const status = statuses.get(id) ?? ''
108 molds.push(moldRow(id, status, snapshot, tasks))
109 if (!isComingSoon(status)) tickets.push(...openTickets(tasks))
110 }
111
112 const products = productRows(await readJson($, `${root}/state/products.json`).catch(() => ({})))
113
114 return { root, loadedAt: now, checkedAt: 0, apps, products, molds, tickets }
115}
116
117/** One health page, given up on after PROBE_MS. */
118async function probe($: $, check: Check): Promise<Check> {
119 const answer = await Promise.race([
120 $.http.fetch(check.url).then(r => r.status).catch(() => 'down' as const),
121 $.clock.sleep(PROBE_MS).then(() => 'down' as const),
122 ])
123 return { ...check, status: answer }
124}
125
126async function refresh($: $): Promise<void> {
127 if (refreshing) return
128 refreshing = true
129 await update($, isRefreshing, () => true)
130 try {
131 const loaded = await load($)
132 await update($, board, () => loaded)
133 const apps = await Promise.all(
134 loaded.apps.map(async app => ({ ...app, checks: await Promise.all(app.checks.map(c => probe($, c))) })),
135 )
136 const checkedAt = await $.clock.now()
137 await update($, board, current => (current ? { ...current, apps, checkedAt } : current))
138 // the last board, kept across sessions, so the next /factory opens on it at once while it refreshes
139 void $.store.set(STORE_KEY, { ...loaded, apps, checkedAt }).catch(() => undefined)
140 $.ui.status(downPrefix(await readUptime($)) + summary(apps, loaded.tickets.length))
141 if (!(await read($, selectedApp))) {
142 const first = apps.find(a => a.status !== 'retired')
143 if (first) await update($, selectedApp, () => first.id)
144 }
145 await readUsage($, apps)
146 await readBuilds($, apps)
147 } catch (error) {
148 const message = error instanceof Error ? error.message : String(error)
149 await update($, board, current => ({
150 ...(current ?? { root, loadedAt: 0, checkedAt: 0, apps: [], products: [], molds: [], tickets: [] }),
151 error: message,
152 }))
153 $.ui.status('factory: could not read state')
154 } finally {
155 refreshing = false
156 await update($, isRefreshing, () => false)
157 }
158}
159
160/* ---- uptime: .runs/uptime/state.json, written every minute by .claude/scripts/uptime.py (a systemd timer) ------------ */
161
162/** Reads the monitor's last state; the board never probes on its behalf. Also puts a down app into the status line. */
163async function readUptime($: $): Promise<Uptime | null> {
164 const raw = await readJson($, `${root}/.runs/uptime/state.json`).catch(() => null)
165 const parsed = parseUptime(raw)
166 await update($, uptime, () => parsed)
167 return parsed
168}
169
170function downPrefix(u: Uptime | null): string {
171 const down = (u?.apps ?? []).filter(a => a.status === 'down').map(a => a.id)
172 return down.length ? `✕ DOWN: ${down.join(', ')} · ` : ''
173}
174
175async function tickUptime($: $): Promise<void> {
176 const u = await readUptime($)
177 const current = await read($, board)
178 if (current && current.checkedAt) $.ui.status(downPrefix(u) + summary(current.apps, current.tickets.length))
179}
180
181/* ---- usage analytics: reports/usage/<app>.json, written by .claude/scripts/app_usage.py -------------------------- */
182
183async function readUsage($: $, apps: readonly AppRow[]): Promise<void> {
184 const found: Record<string, Usage> = {}
185 for (const app of apps) {
186 const raw = await readJson($, `${root}/reports/usage/${app.id}.json`).catch(() => null)
187 if (raw) found[app.id] = parseUsage(raw)
188 }
189 await update($, usage, () => found)
190}
191
192/** Runs the read-only collector for every app, then reads what it wrote. */
193async function collect($: $): Promise<void> {
194 if (collecting) return
195 collecting = true
196 await update($, isCollecting, () => true)
197 await update($, collectError, () => '')
198 try {
199 const script = `${root}/.claude/scripts/app_usage.py`
200 if (!(await $.fs.exists(script))) {
201 await update($, collectError, () => 'The usage collector (.claude/scripts/app_usage.py) is not in this factory yet.')
202 return
203 }
204 const ran = await $.process.run(['python3', script, '--all'], { cwd: root, timeoutMs: COLLECT_MS })
205 if (ran.exitCode !== 0) {
206 const why = (ran.stderr || ran.stdout).trim().split('\n').slice(-1)[0] ?? ''
207 await update($, collectError, () => `The usage collector stopped (exit ${ran.exitCode}). ${why}`.trim())
208 }
209 const current = await read($, board)
210 if (current) await readUsage($, current.apps)
211 } catch (error) {
212 await update($, collectError, () => (error instanceof Error ? error.message : String(error)))
213 } finally {
214 collecting = false
215 await update($, isCollecting, () => false)
216 }
217}
218
219/* ---- build cost and time: reports/mint/<app>.json, written by .claude/scripts/mint_report.py --all -------------------- */
220
221async function readBuilds($: $, apps: readonly AppRow[]): Promise<void> {
222 const found: Record<string, Build> = {}
223 for (const app of apps) {
224 const raw = await readJson($, `${root}/reports/mint/${app.id}.json`).catch(() => null)
225 if (raw) found[app.id] = parseBuild(raw)
226 }
227 await update($, builds, () => found)
228}
229
230/** Recomputes every app's build cost and time from the agent sessions and git (offline, read-only), then reads them. */
231async function computeBuilds($: $): Promise<void> {
232 if (computing) return
233 computing = true
234 await update($, isComputing, () => true)
235 await update($, computeError, () => '')
236 try {
237 const script = `${root}/.claude/scripts/mint_report.py`
238 if (!(await $.fs.exists(script))) {
239 await update($, computeError, () => 'The build report (.claude/scripts/mint_report.py) is not in this factory yet.')
240 return
241 }
242 const ran = await $.process.run(['python3', script, '--all', '--json'], { cwd: root, timeoutMs: COMPUTE_MS })
243 if (ran.exitCode !== 0) {
244 const why = (ran.stderr || ran.stdout).trim().split('\n').slice(-1)[0] ?? ''
245 await update($, computeError, () => `The build report stopped (exit ${ran.exitCode}). ${why}`.trim())
246 }
247 const current = await read($, board)
248 if (current) await readBuilds($, current.apps)
249 } catch (error) {
250 await update($, computeError, () => (error instanceof Error ? error.message : String(error)))
251 } finally {
252 computing = false
253 await update($, isComputing, () => false)
254 }
255}
256
257function summary(apps: readonly AppRow[], open: number): string {
258 const live = apps.filter(a => a.checks.length > 0)
259 const healthy = live.filter(a => verdict(a.checks) === 'healthy').length
260 const down = live.length - healthy
261 return `factory: ${healthy}/${live.length} apps healthy${down ? ` · ${down} down` : ''} · ${open} open ticket${open === 1 ? '' : 's'}`
262}
263
264const COLOR = { healthy: 'success', down: 'error', pending: 'subtle', 'n/a': 'inactive' } as const
265
266function stageColor(status: string): string {
267 if (status === 'reverted') return 'error'
268 if (status === 'retired') return 'inactive'
269 return 'text'
270}
271
272const TABS: { id: Tab; label: string }[] = [
273 { id: 'products', label: 'Products & apps' },
274 { id: 'molds', label: 'Molds' },
275 { id: 'tickets', label: 'Tickets' },
276 { id: 'analytics', label: 'Analytics' },
277]
278
279
280export const register: Register = on => {
281 on('session.start', async ($, e, next) => {
282 const started = await next(e)
283 if (e.cwd && (await $.fs.exists(`${e.cwd}/state/factory.json`))) root = e.cwd
284 await update($, isRefreshing, () => false)
285 await update($, isCollecting, () => false)
286 await update($, isComputing, () => false)
287 if (!(await read($, board))) {
288 const last = (await $.store.get(STORE_KEY).catch(() => undefined)) as Board | undefined
289 if (last && last.root === root) await update($, board, () => last)
290 }
291 await $.command.register({
292 name: 'factory',
293 description: 'Factory board: products and apps, molds, tickets, analytics (/factory products|molds|tickets|analytics [app]|refresh|<ticket id>)',
294 })
295 void refresh($)
296 $.clock.every(EVERY_MS, () => void refresh($))
297 $.clock.every(USAGE_EVERY_MS, () => void collect($))
298 $.clock.every(BUILD_EVERY_MS, () => void computeBuilds($))
299 $.clock.every(UPTIME_EVERY_MS, () => void tickUptime($))
300 return started
301 })
302
303 on('command.run', { command: 'factory' }, async ($, e) => {
304 const [word = '', target = ''] = e.args.trim().split(/\s+/)
305 const arg = word
306 if (word === 'analytics' && target) await update($, selectedApp, () => target)
307 if (arg === 'apps') await update($, tab, () => 'products')
308 else if (arg === 'products' || arg === 'molds' || arg === 'tickets' || arg === 'analytics') await update($, tab, () => arg)
309 else if (/^[a-z0-9_]+-\d+$/i.test(arg)) {
310 await update($, openTicket, () => arg)
311 await update($, tab, () => 'tickets')
312 }
313 await $.ui.open({ id: PANE, title: 'Factory board' })
314 void refresh($)
315 if (arg === 'analytics' && Object.keys(await read($, usage)).length === 0) void collect($)
316 const current = await read($, board)
317 return { text: current ? summary(current.apps, current.tickets.length) : 'Factory board opened.' }
318 })
319
320 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
321 const { Box, Text, Button } = $.ui.resolve(e)
322 const current = await read($, board)
323 const busy = await read($, isRefreshing)
324 const shown = await read($, tab)
325 const now = await $.clock.now()
326 const banner = uptimeBanner(await read($, uptime), now)
327
328 if (!current) return <Text dimColor>{busy ? 'Reading the factory state…' : 'No data yet. Run /factory refresh.'}</Text>
329
330 const tabs = (
331 <Box flexDirection="row" gap={1}>
332 {TABS.map(t => (
333 <Button
334 key={`tab-${t.id}`}
335 label={t.label}
336 variant={t.id === shown ? 'primary' : 'secondary'}
337 onPress={() => void update($, tab, () => t.id)}
338 />
339 ))}
340 </Box>
341 )
342
343 /* -- What it took to build an app (or a product): apportioned and estimated parts are dimmed ------------------ */
344 const buildsByApp = await read($, builds)
345 const computingBuilds = await read($, isComputing)
346 const computeProblem = await read($, computeError)
347 const buildLine = (key: string, b: Build | null | undefined, indent = 2) => (
348 <Box key={key} flexDirection="row" gap={1} paddingLeft={indent}>
349 {buildParts(b).map((part, i) => (
350 <Text key={`${key}-${i}`} dimColor={part.dim}>
351 {part.text}
352 </Text>
353 ))}
354 </Box>
355 )
356
357 /* -- One app: its health checks, deploy, base code and address ------------------------------------------------ */
358 const appCard = (app: AppRow) => {
359 const health = verdict(app.checks)
360 return (
361 <Box flexDirection="column" key={`app-${app.id}`} paddingLeft={2}>
362 <Box flexDirection="row" gap={1}>
363 <Text color={COLOR[health]}>{health === 'healthy' ? '●' : health === 'down' ? '✕' : '○'}</Text>
364 <Text bold>{app.id}</Text>
365 <Text color={COLOR[health]}>{health}</Text>
366 {app.status !== 'stamped' && <Text color={stageColor(app.status)}>{app.status}</Text>}
367 <Text dimColor>{app.target === 'vm_remote' ? 'own server' : app.target}</Text>
368 </Box>
369 <Box flexDirection="row" gap={1} paddingLeft={2}>
370 {app.checks.map(c => (
371 <Text
372 key={c.name}
373 color={typeof c.status === 'number' && c.status < 300 ? 'success' : c.status === 'pending' ? 'subtle' : 'error'}
374 >
375 {c.name} {String(c.status)}
376 </Text>
377 ))}
378 {app.rls && <Text dimColor>RLS {app.rls}</Text>}
379 <Text dimColor>· deployed {ago(app.deployedAt, now)}</Text>
380 <Text color={app.isCurrent ? 'success' : 'warning'}>
381 {app.moldId}@{app.moldCommit || '?'} {app.isCurrent ? 'current' : 'behind'}
382 </Text>
383 </Box>
384 {app.url && (
385 <Text dimColor wrap="truncate">
386 {' '}
387 {app.url}
388 </Text>
389 )}
390 {buildLine(`build-${app.id}`, buildsByApp[app.id])}
391 </Box>
392 )
393 }
394
395 /* -- shared --------------------------------------------------------------------------------------------------- */
396 const width = Math.max(40, (e.props as { bodyColumns?: number } | undefined)?.bodyColumns ?? 100)
397 const priorityColor = (p: number) => (p <= 1 ? 'error' : p === 2 ? 'warning' : 'subtle')
398 const appOf = (t: Ticket) => current.apps.find(a => ticketsForApp([t], a.id).length > 0)?.id
399 const shortId = (id: string) => id.replace(/^mold_v\d+-/, '#')
400 const showTicket = async (id: string) => {
401 await update($, openTicket, () => id)
402 await update($, tab, () => 'tickets')
403 }
404
405 /* One ticket as one line: priority, number, app, then the title as the thing to click. */
406 const ticketRow = (t: Ticket, keyPrefix = 'ticket') => {
407 const app = appOf(t)
408 const used = 4 + 6 + (app ? app.length + 2 : 0) + (t.status === 'in_progress' ? 13 : 0) + 4
409 return (
410 <Box key={`${keyPrefix}-row-${t.id}`} flexDirection="row" gap={1}>
411 <Text bold color={priorityColor(t.priority)}>
412 P{t.priority}
413 </Text>
414 <Text dimColor>{shortId(t.id).padEnd(5)}</Text>
415 {t.status === 'in_progress' && <Text color="suggestion">in progress</Text>}
416 {t.status === 'blocked' && <Text color="error">blocked</Text>}
417 {app && <Text color="claude">{app}</Text>}
418 <Button key={`${keyPrefix}-${t.id}`} plain label={fit(plainTitle(t), width - used)} onPress={() => void showTicket(t.id)} />
419 </Box>
420 )
421 }
422
423 /* -- Products ------------------------------------------------------------------------------------------------- */
424 const unowned = current.apps.filter(a => !current.products.some(p => p.appIds.includes(a.id)))
425 const productsTab = (
426 <Box flexDirection="column" gap={1}>
427 {current.apps.length === 0 && <Text dimColor>No apps in state/application.</Text>}
428 {current.products.map(p => {
429 const soon = isComingSoon(current.molds.find(m => m.id === p.moldId)?.status ?? '')
430 const apps = current.apps.filter(a => p.appIds.includes(a.id))
431 return (
432 <Box key={`product-${p.id}`} flexDirection="column">
433 <Box flexDirection="row" gap={1}>
434 <Text bold>{p.name}</Text>
435 {soon && <Text color="claude">coming soon</Text>}
436 <Text dimColor>{p.moldId}</Text>
437 </Box>
438 {p.appIds.length > 0 &&
439 buildLine(
440 `pbuild-${p.id}`,
441 sumBuilds(p.appIds.map(id => buildsByApp[id]).filter((b): b is Build => b !== undefined)),
442 0,
443 )}
444 {!soon && (
445 <Box flexDirection="row">
446 {STAGES.map((s, i) => {
447 const at = STAGES.indexOf(p.stage as (typeof STAGES)[number])
448 const done = i <= at
449 return (
450 <Text key={`stage-${p.id}-${s}`} color={i === at ? 'success' : done ? 'text' : 'inactive'} bold={i === at}>
451 {i > 0 ? ' → ' : ''}
452 {i === at ? '● ' : done ? '✓ ' : '○ '}
453 {STAGE_WORDS[s]}
454 </Text>
455 )
456 })}
457 </Box>
458 )}
459 {apps.map(a => appCard(a))}
460 </Box>
461 )
462 })}
463 {unowned.length > 0 && (
464 <Box flexDirection="column">
465 <Text bold>Other apps</Text>
466 {unowned.map(a => appCard(a))}
467 </Box>
468 )}
469 <Text dimColor wrap="wrap">
470 built: the AI the factory spent making it, at list prices. (shared): split from sessions that also built other apps;
471 ~ est.: work not yet counted, estimated.
472 </Text>
473 {computeProblem && <Text color="error">{computeProblem}</Text>}
474 </Box>
475 )
476
477 /* -- Molds ---------------------------------------------------------------------------------------------------- */
478 const moldsTab = (
479 <Box flexDirection="column">
480 {current.molds.map(m =>
481 isComingSoon(m.status) ? (
482 <Box key={`mold-${m.id}`} flexDirection="row" gap={1}>
483 <Text bold>{m.id}</Text>
484 <Text color="claude">coming soon</Text>
485 <Text dimColor>{m.open + m.inProgress} planned tickets</Text>
486 </Box>
487 ) : (
488 <Box key={`mold-${m.id}`} flexDirection="row" gap={1}>
489 <Text bold>{m.id}</Text>
490 <Text dimColor>@{m.snapshot}</Text>
491 <Text color={m.open + m.inProgress ? 'warning' : 'success'}>{m.open} open</Text>
492 <Text>{m.inProgress} in progress</Text>
493 <Text dimColor>{m.done} done</Text>
494 </Box>
495 ),
496 )}
497 </Box>
498 )
499
500 /* -- One ticket ----------------------------------------------------------------------------------------------- */
501 const openId = await read($, openTicket)
502 const opened: Ticket | undefined = current.tickets.find(t => t.id === openId)
503 const rule = '─'.repeat(Math.min(width - 2, 72))
504
505 const detail = opened && (
506 <Box flexDirection="column" gap={1}>
507 <Box flexDirection="row" gap={1}>
508 <Button key="ticket-back" label="← Back" onPress={() => void update($, openTicket, () => '')} />
509 <Button
510 key="ticket-work"
511 label="Work on this"
512 variant="primary"
513 onPress={() =>
514 void $.prompt.fill({
515 text: `Work on factory ticket ${opened.id} (${opened.mold}): ${opened.title}`,
516 mode: 'replace',
517 })
518 }
519 />
520 </Box>
521 <Box flexDirection="column">
522 <Box flexDirection="row" gap={1}>
523 <Text bold color={priorityColor(opened.priority)}>
524 P{opened.priority}
525 </Text>
526 <Text>{opened.status.replace('_', ' ')}</Text>
527 {appOf(opened) && <Text color="claude">{appOf(opened)}</Text>}
528 <Text dimColor>{opened.id}</Text>
529 </Box>
530 <Text bold wrap="wrap">
531 {opened.title}
532 </Text>
533 <Text dimColor>{rule}</Text>
534 <Text dimColor wrap="wrap">
535 {[
536 opened.type,
537 opened.lane ? `${opened.lane} lane` : '',
538 opened.product ? `product ${opened.product}` : '',
539 opened.advancesStage ? `moves it to ${STAGE_WORDS[opened.advancesStage] ?? opened.advancesStage}` : '',
540 opened.owner ? `owner ${opened.owner}` : '',
541 opened.created ? `filed ${opened.created}` : '',
542 opened.updated && opened.updated !== opened.created ? `updated ${opened.updated}` : '',
543 ]
544 .filter(Boolean)
545 .join(' · ')}
546 </Text>
547 </Box>
548 {opened.detail && (
549 <Box flexDirection="column">
550 <Text bold>What happened</Text>
551 <Text wrap="wrap">{opened.detail}</Text>
552 </Box>
553 )}
554 {opened.acceptance && opened.acceptance.length > 0 && (
555 <Box flexDirection="column">
556 <Text bold>Done when</Text>
557 {opened.acceptance.map((a, i) => (
558 <Text key={`acc-${i}`} wrap="wrap">
559 ☐ {a}
560 </Text>
561 ))}
562 </Box>
563 )}
564 {opened.dependsOn && opened.dependsOn.length > 0 && (
565 <Box flexDirection="column">
566 <Text bold>Waits on</Text>
567 {opened.dependsOn.map(d => {
568 const other = current.tickets.find(t => t.id === d)
569 return other ? (
570 ticketRow(other, 'dep')
571 ) : (
572 <Text key={`dep-done-${d}`} color="success">
573 ✓ {d} done
574 </Text>
575 )
576 })}
577 </Box>
578 )}
579 {opened.evidence && opened.evidence.length > 0 && (
580 <Box flexDirection="column">
581 <Text bold>Evidence so far</Text>
582 {opened.evidence.map((a, i) => (
583 <Text key={`ev-${i}`} dimColor wrap="wrap">
584 · {a}
585 </Text>
586 ))}
587 </Box>
588 )}
589 </Box>
590 )
591
592 /* -- Open tickets --------------------------------------------------------------------------------------------- */
593 const filter = (await read($, ticketFilter)) || 'all'
594 const shownTickets = current.tickets.filter(t => ticketMatches(t, filter))
595 const priorities = [...new Set(current.tickets.map(t => t.priority))].sort()
596 const ticketApps = current.apps.filter(a => ticketsForApp(current.tickets, a.id).length > 0)
597 const chip = (id: string, label: string) => (
598 <Button
599 key={`filter-${id}`}
600 label={label}
601 variant={filter === id ? 'primary' : 'secondary'}
602 onPress={() => void update($, ticketFilter, () => id)}
603 />
604 )
605 const activeMolds = current.molds.filter(m => !isComingSoon(m.status))
606 const ticketsTab = (
607 <Box flexDirection="column" gap={1}>
608 <Box flexDirection="row" gap={1} flexWrap="wrap">
609 {chip('all', `All ${current.tickets.length}`)}
610 {priorities.map(p => chip(`p${p}`, `P${p} ${current.tickets.filter(t => t.priority === p).length}`))}
611 {ticketApps.map(a => chip(`app:${a.id}`, `${a.id} ${ticketsForApp(current.tickets, a.id).length}`))}
612 </Box>
613 {shownTickets.length === 0 && <Text color="success">Nothing open{filter === 'all' ? '' : ' for this filter'}.</Text>}
614 {activeMolds.map(m => {
615 const mine = shownTickets.filter(t => t.mold === m.id)
616 if (mine.length === 0) return null
617 return (
618 <Box flexDirection="column" key={`tickets-${m.id}`}>
619 <Text dimColor>
620 {m.id} · {mine.length} open
621 </Text>
622 {mine.map(t => ticketRow(t))}
623 </Box>
624 )
625 })}
626 {current.molds.some(m => isComingSoon(m.status)) && (
627 <Text dimColor>
628 Coming-soon molds' planned tickets are not listed (
629 {current.molds
630 .filter(m => isComingSoon(m.status))
631 .map(m => `${m.id} ${m.open + m.inProgress}`)
632 .join(', ')}
633 ).
634 </Text>
635 )}
636 </Box>
637 )
638
639 /* -- Analytics ------------------------------------------------------------------------------------------------ */
640 const reports = await read($, usage)
641 const collecting = await read($, isCollecting)
642 const collectProblem = await read($, collectError)
643 const liveApps = current.apps.filter(a => a.status !== 'retired')
644 const chosenId = (await read($, selectedApp)) || liveApps[0]?.id || ''
645 const chosen = reports[chosenId]
646 const factoryTickets = ticketsForApp(current.tickets, chosenId)
647
648 const analyticsTab = (
649 <Box flexDirection="column" gap={1}>
650 <Box flexDirection="row" gap={1}>
651 {liveApps.map(a => (
652 <Button
653 key={`app-${a.id}`}
654 label={a.id}
655 variant={a.id === chosenId ? 'primary' : 'secondary'}
656 onPress={() => void update($, selectedApp, () => a.id)}
657 />
658 ))}
659 </Box>
660
661 {!chosen && (
662 <Text dimColor>
663 {collecting ? 'Collecting usage from the apps…' : 'No usage report for this app yet. Press Collect usage.'}
664 </Text>
665 )}
666
667 {chosen && (
668 <Box flexDirection="column" gap={1}>
669 <Text dimColor>
670 Last {chosen.days} days · collected {ago(chosen.generated_at, now)}
671 </Text>
672 {chosen.error && <Text color="error">{chosen.error}</Text>}
673
674 <Box flexDirection="column">
675 <Text bold>Usage</Text>
676 <Text>
677 <Text color="suggestion">{short(chosen.totals.people_active)}</Text> people active ·{' '}
678 <Text color="suggestion">{short(chosen.totals.chats)}</Text> chats ·{' '}
679 <Text color="suggestion">{short(chosen.totals.chat_turns)}</Text> chat turns
680 </Text>
681 <Text>
682 {short(chosen.totals.input_tokens)} tokens in · {short(chosen.totals.output_tokens)} out · chat cost{' '}
683 {money(chosen.totals.cost_usd)}
684 </Text>
685 <Text>
686 {short(chosen.totals.workflow_runs)} workflow runs · workflow cost{' '}
687 <Text dimColor={isEstimate(chosen.totals.workflow_cost_basis)}>
688 {cost(chosen.totals.workflow_cost_usd, chosen.totals.workflow_cost_basis)}
689 </Text>
690 </Text>
691 </Box>
692
693 {chosen.daily.length > 0 && (
694 <Box flexDirection="column">
695 <Text bold>Chat turns per day</Text>
696 <Text color="suggestion">{sparkline(chosen.daily.map(d => d.chat_turns))}</Text>
697 <Text dimColor>
698 {chosen.daily[0]?.date} → {chosen.daily[chosen.daily.length - 1]?.date} · busiest day{' '}
699 {Math.max(0, ...chosen.daily.map(d => d.chat_turns ?? 0))} turns
700 </Text>
701 </Box>
702 )}
703
704 <Box flexDirection="column">
705 <Text bold>Tickets in the app</Text>
706 <Text>
707 <Text color={chosen.totals.tickets.open ? 'warning' : 'success'}>{short(chosen.totals.tickets.open)} open</Text> ·{' '}
708 {short(chosen.totals.tickets.in_progress)} in progress · {short(chosen.totals.tickets.done)} done{' '}
709 <Text dimColor>({short(chosen.totals.tickets.total)} in all)</Text>
710 </Text>
711 </Box>
712
713 {chosen.workspaces.length > 0 && (
714 <Box flexDirection="column">
715 <Text bold>By workspace</Text>
716 {chosen.workspaces.map(w => (
717 <Text key={w.org_id} wrap="truncate">
718 {w.name}: {short(w.people_active)} people · {short(w.chats)} chats · {short(w.chat_turns)} turns · chat{' '}
719 {money(w.cost_usd)} · {short(w.workflow_runs)} workflow runs · {short(w.tickets.open)} open tickets
720 </Text>
721 ))}
722 </Box>
723 )}
724
725 <Box flexDirection="column">
726 <Text bold>By agent</Text>
727 {chosen.by_agent.length === 0 && <Text dimColor>Not collected yet: press Collect usage.</Text>}
728 {chosen.by_agent.slice(0, 12).map(a => (
729 <Box key={`agent-${a.workspace}-${a.agent}`} flexDirection="row" gap={1}>
730 <Text bold={a.kind === 'main'} color={a.kind === 'main' ? 'suggestion' : 'text'}>
731 {fit(a.agent, 26).padEnd(26)}
732 </Text>
733 <Text>{a.kind === 'main' ? `${short(a.turns)} turns` : `${short(a.runs)} runs`}</Text>
734 <Text dimColor>
735 {short(a.input_tokens)} in · {short(a.output_tokens)} out ·
736 </Text>
737 {/* An estimate is dimmed and marked "~ est." so it never reads as a measured figure. */}
738 <Text dimColor={isEstimate(a.cost_basis)}>{cost(a.cost_usd, a.cost_basis)}</Text>
739 {chosen.workspaces.length > 1 && <Text dimColor>· {a.workspace}</Text>}
740 </Box>
741 ))}
742 </Box>
743
744 <Box flexDirection="column">
745 <Text bold>By user</Text>
746 {chosen.by_user.length === 0 && <Text dimColor>Not collected yet: press Collect usage.</Text>}
747 {chosen.by_user.slice(0, 12).map(u => (
748 <Box key={`user-${u.workspace}-${u.user}`} flexDirection="row" gap={1}>
749 <Text bold>{fit(u.user, 22).padEnd(22)}</Text>
750 <Text>
751 {short(u.chats)} chats · {short(u.chat_turns)} turns
752 </Text>
753 <Text dimColor>
754 · {money(u.cost_usd)}
755 {u.last_active ? ` · last ${ago(u.last_active, now)}` : ''}
756 </Text>
757 {chosen.workspaces.length > 1 && <Text dimColor>· {u.workspace}</Text>}
758 </Box>
759 ))}
760 </Box>
761 </Box>
762 )}
763
764 <Box flexDirection="column">
765 <Text bold>Factory tickets for this app</Text>
766 {factoryTickets.length === 0 && <Text color="success">None open.</Text>}
767 {factoryTickets.map(t => ticketRow(t, 'aticket'))}
768 </Box>
769
770 {collectProblem && <Text color="error">{collectProblem}</Text>}
771 </Box>
772 )
773
774 return (
775 <Box flexDirection="column" gap={1}>
776 {banner.level === 'down' && (
777 <Box key="uptime-banner" flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
778 <Text bold color="error">
779 ✕ An app is down. The factory is looking at it.
780 </Text>
781 {banner.lines.map((line, i) => (
782 <Text key={`uptime-line-${i}`} color="error" wrap="wrap">
783 {line}
784 </Text>
785 ))}
786 </Box>
787 )}
788 {banner.level === 'stale' && (
789 <Text key="uptime-stale" color="warning" wrap="wrap">
790 {banner.lines.join(' ')}
791 </Text>
792 )}
793 {tabs}
794 {current.error && <Text color="error">Could not read {current.root}/state: {current.error}</Text>}
795 {shown === 'products' && productsTab}
796 {shown === 'molds' && moldsTab}
797 {shown === 'tickets' && (detail || ticketsTab)}
798 {shown === 'analytics' && analyticsTab}
799 <Box flexDirection="row" gap={1}>
800 <Button key="refresh" label={busy ? 'Refreshing…' : 'Refresh'} onPress={() => void refresh($)} />
801 {shown === 'products' && (
802 <Button
803 key="compute"
804 label={computingBuilds ? 'Computing build costs…' : 'Recompute build costs'}
805 onPress={() => void computeBuilds($)}
806 />
807 )}
808 {shown === 'analytics' && (
809 <Button key="collect" label={collecting ? 'Collecting…' : 'Collect usage'} onPress={() => void collect($)} />
810 )}
811 <Text dimColor>
812 health checked {current.checkedAt ? ago(new Date(current.checkedAt).toISOString(), now) : 'pending'} · every 5 min
813 </Text>
814 </Box>
815 </Box>
816 )
817 })
818}
819hooks/board.ts 429 lines1import type { AgentUsage, AppRow, Build, BuildBasis, Check, CostBasis, Measured, MoldRow, ProductRow, Ticket, Uptime, UptimeApp, UptimeBanner, Usage, UsageNumbers, UserUsage } from '../types'
2
3// Pure readers of the factory's own state files (state/factory.json, state/products.json,
4// state/application/<id>/*.json, state/tasks/<mold>.jsonl). No I/O here, so the tests drive them directly.
5
6type Json = Record<string, any>
7
8const OPEN = new Set(['todo', 'in_progress', 'blocked'])
9
10/** The latest record per task id: a task file may carry the same id more than once. */
11export function parseTasks(jsonl: string): Ticket[] {
12 const byId = new Map<string, Ticket>()
13 for (const line of jsonl.split('\n')) {
14 if (!line.trim()) continue
15 let row: Json
16 try {
17 row = JSON.parse(line)
18 } catch {
19 continue
20 }
21 if (typeof row.task_id !== 'string') continue
22 byId.set(row.task_id, {
23 id: row.task_id,
24 mold: String(row.mold_id ?? ''),
25 title: String(row.title ?? ''),
26 status: String(row.status ?? ''),
27 priority: Number(row.priority ?? 9),
28 ...(row.type ? { type: String(row.type) } : {}),
29 ...(row.owner ? { owner: String(row.owner) } : {}),
30 ...(row.product_id ? { product: String(row.product_id) } : {}),
31 ...(row.created ? { created: String(row.created) } : {}),
32 ...(row.updated ? { updated: String(row.updated) } : {}),
33 ...(row.advances_stage ? { advancesStage: String(row.advances_stage) } : {}),
34 ...(Array.isArray(row.deps ?? row.depends_on) ? { dependsOn: (row.deps ?? row.depends_on).map(String) } : {}),
35 ...(Array.isArray(row.acceptance) ? { acceptance: row.acceptance.map(String) } : {}),
36 ...(Array.isArray(row.evidence) ? { evidence: row.evidence.map(String) } : {}),
37 ...(row.detail ? { detail: String(row.detail) } : {}),
38 ...(row.lane ? { lane: String(row.lane) } : {}),
39 })
40 }
41 return [...byId.values()]
42}
43
44export function moldRow(id: string, status: string, snapshot: string, tasks: readonly Ticket[]): MoldRow {
45 return {
46 id,
47 status,
48 snapshot: snapshot.slice(0, 7),
49 open: tasks.filter(t => t.status === 'todo' || t.status === 'blocked').length,
50 inProgress: tasks.filter(t => t.status === 'in_progress').length,
51 done: tasks.filter(t => t.status === 'done').length,
52 }
53}
54
55export function openTickets(tasks: readonly Ticket[]): Ticket[] {
56 return tasks
57 .filter(t => OPEN.has(t.status))
58 .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id))
59}
60
61/** The health pages to probe for one app, by where it runs; none for a retired app. */
62export function healthChecks(application: Json, infrastructure: Json): Check[] {
63 if (application.status === 'retired') return []
64 const target = String(infrastructure.target ?? '')
65 const vercel = infrastructure.vercel ?? {}
66 const remote = infrastructure.vm_remote ?? {}
67 const web = target === 'vm_remote' ? remote.production_url : vercel.production_url
68 const checks: Check[] = []
69 if (web) checks.push({ name: 'web', url: `${web}/api/ops/health`, status: 'pending' })
70 if (target === 'vm_remote' && web) checks.push({ name: 'api', url: `${web}/eve/v1/health`, status: 'pending' })
71 if (target !== 'vm_remote' && vercel.api_url) checks.push({ name: 'api', url: `${vercel.api_url}/eve/v1/health`, status: 'pending' })
72 if (target !== 'vm_remote' && vercel.workflow_url) {
73 checks.push({ name: 'workflow', url: `${vercel.workflow_url}/api/health`, status: 'pending' })
74 }
75 return checks
76}
77
78export function appRow(id: string, application: Json, infrastructure: Json, datastores: Json, moldSnapshots: Map<string, string>): AppRow {
79 const target = String(infrastructure.target ?? '?')
80 const url = target === 'vm_remote' ? infrastructure.vm_remote?.production_url : infrastructure.vercel?.production_url
81 const rls = datastores.postgres?.rls_verified
82 const moldId = String(application.mold_id ?? '')
83 const moldCommit = String(application.mold_commit ?? '')
84 const snapshot = moldSnapshots.get(moldId) ?? ''
85 return {
86 id,
87 product: String(application.product_id ?? ''),
88 target,
89 status: String(application.status ?? '?'),
90 moldId,
91 moldCommit: moldCommit.slice(0, 7),
92 isCurrent: moldCommit !== '' && moldCommit === snapshot,
93 url: String(url ?? ''),
94 deployedAt: String(infrastructure.deployed_at ?? ''),
95 rls: rls ? `${rls.protected ?? '?'}/${rls.org_scoped_tables ?? '?'}` : '',
96 checks: healthChecks(application, infrastructure),
97 }
98}
99
100export function productRows(products: Json): ProductRow[] {
101 return (products.products ?? []).map((p: Json) => ({
102 id: String(p.product_id ?? ''),
103 name: String(p.name ?? p.product_id ?? ''),
104 stage: String(p.stage ?? ''),
105 apps: Array.isArray(p.app_ids) ? p.app_ids.length : 0,
106 moldId: String(p.mold_id ?? ''),
107 appIds: Array.isArray(p.app_ids) ? p.app_ids.map(String) : [],
108 }))
109}
110
111/** "3h ago" from an ISO time and now. */
112export function ago(iso: string, now: number): string {
113 const at = Date.parse(iso)
114 if (!Number.isFinite(at)) return '—'
115 const minutes = Math.max(0, Math.round((now - at) / 60_000))
116 if (minutes < 60) return `${minutes}m ago`
117 const hours = Math.round(minutes / 60)
118 if (hours < 48) return `${hours}h ago`
119 return `${Math.round(hours / 24)}d ago`
120}
121
122/** Healthy when every probe answered 2xx; down when any did not; pending until probed. */
123export function verdict(checks: readonly Check[]): 'healthy' | 'down' | 'pending' | 'n/a' {
124 if (checks.length === 0) return 'n/a'
125 if (checks.some(c => c.status === 'pending')) return 'pending'
126 return checks.every(c => typeof c.status === 'number' && c.status >= 200 && c.status < 300) ? 'healthy' : 'down'
127}
128
129/** A mold that is announced but not yet buildable. */
130export const isComingSoon = (status: string): boolean => status === 'coming_soon'
131
132const BARS = '▁▂▃▄▅▆▇█'
133
134/** A one-line bar chart of the values, scaled to the largest; all zero draws the lowest bar. */
135export function sparkline(values: readonly (number | null)[]): string {
136 const top = Math.max(0, ...values.map(v => v ?? 0))
137 return values.map(v => (v === null ? ' ' : BARS[top === 0 ? 0 : Math.min(7, Math.round((v / top) * 7))])).join('')
138}
139
140const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
141
142/** A report's cost_basis, or null when it says none (an older report, or nothing to cost). */
143export const costBasis = (v: unknown): CostBasis | null => (v === 'recorded' || v === 'estimated' || v === 'mixed' ? v : null)
144
145/** Is this figure (at least partly) an estimate? Then it is shown as one, never as measured. */
146export const isEstimate = (basis: CostBasis | null | undefined): boolean => basis === 'estimated' || basis === 'mixed'
147
148function numbers(raw: Json | undefined): UsageNumbers {
149 const t = raw?.tickets ?? {}
150 return {
151 people_active: num(raw?.people_active),
152 chats: num(raw?.chats),
153 chat_turns: num(raw?.chat_turns),
154 input_tokens: num(raw?.input_tokens),
155 output_tokens: num(raw?.output_tokens),
156 cost_usd: num(raw?.cost_usd),
157 workflow_runs: num(raw?.workflow_runs),
158 workflow_cost_usd: num(raw?.workflow_cost_usd),
159 workflow_cost_basis: costBasis(raw?.workflow_cost_basis),
160 tickets: { open: num(t.open), in_progress: num(t.in_progress), done: num(t.done), total: num(t.total) },
161 }
162}
163
164/** The app_usage.py report, tolerant of missing fields (a missing number reads 0 and the report says what it did not measure). */
165export function parseUsage(raw: Json): Usage {
166 return {
167 app_id: String(raw.app_id ?? ''),
168 generated_at: String(raw.generated_at ?? ''),
169 days: num(raw.days) ?? 30,
170 totals: numbers(raw.totals),
171 workspaces: (Array.isArray(raw.workspaces) ? raw.workspaces : []).map((w: Json) => ({
172 ...numbers(w),
173 org_id: String(w.org_id ?? ''),
174 name: String(w.name ?? w.org_id ?? ''),
175 })),
176 daily: (Array.isArray(raw.daily) ? raw.daily : []).map((d: Json) => ({
177 date: String(d.date ?? ''),
178 chat_turns: num(d.chat_turns),
179 people_active: num(d.people_active),
180 })),
181 not_measured: (Array.isArray(raw.not_measured) ? raw.not_measured : []).map(String),
182 by_agent: (Array.isArray(raw.by_agent) ? raw.by_agent : []).map(
183 (a: Json): AgentUsage => ({
184 agent: String(a.agent ?? ''),
185 kind: a.kind === 'main' ? 'main' : 'specialist',
186 workspace: String(a.workspace ?? ''),
187 turns: num(a.turns),
188 runs: num(a.runs),
189 input_tokens: num(a.input_tokens),
190 output_tokens: num(a.output_tokens),
191 cost_usd: num(a.cost_usd),
192 cost_basis: costBasis(a.cost_basis),
193 estimated_from: typeof a.estimated_from === 'string' && a.estimated_from ? a.estimated_from : null,
194 last_active: String(a.last_active ?? ''),
195 }),
196 ),
197 by_user: (Array.isArray(raw.by_user) ? raw.by_user : []).map(
198 (u: Json): UserUsage => ({
199 user: String(u.user ?? ''),
200 workspace: String(u.workspace ?? ''),
201 chats: num(u.chats),
202 chat_turns: num(u.chat_turns),
203 input_tokens: num(u.input_tokens),
204 output_tokens: num(u.output_tokens),
205 cost_usd: num(u.cost_usd),
206 workflow_runs: num(u.workflow_runs),
207 last_active: String(u.last_active ?? ''),
208 }),
209 ),
210 ...(raw.error ? { error: String(raw.error) } : {}),
211 }
212}
213
214/** The factory's open tickets that name this app. */
215export function ticketsForApp(tickets: readonly Ticket[], appId: string): Ticket[] {
216 const word = new RegExp(`(^|[^a-z0-9_])${appId.replace(/[^a-z0-9_]/gi, '')}($|[^a-z0-9_])`, 'i')
217 return tickets.filter(t => word.test(t.title))
218}
219
220/** 1234567 -> "1.2M", 1234 -> "1.2k"; not measured -> "—". */
221export function short(n: number | null): string {
222 if (n === null) return '—'
223 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
224 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
225 return String(Math.round(n))
226}
227
228/** "$13.56", "$1,204"; not measured -> "not measured". */
229export function money(n: number | null): string {
230 if (n === null) return 'not measured'
231 return `$${n < 1000 ? n.toFixed(2) : Math.round(n).toLocaleString("en-US")}`
232}
233
234/** money(), and an estimate as "~$1.23 est." so it never reads as a measured figure (the board also dims it). */
235export function cost(n: number | null, basis?: CostBasis | null): string {
236 if (n === null || !isEstimate(basis)) return money(n)
237 return `~${money(n)} est.`
238}
239
240/** a + b, null when either is not measured. */
241export const plus = (a: number | null, b: number | null): number | null => (a === null || b === null ? null : a + b)
242
243/** A product's stages, in order (state/products.json, docs/PRODUCTS.md). */
244export const STAGES = ['defined', 'stamped', 'lanes_passing', 'deployed', 'released'] as const
245
246/** The words the board shows for each stage. */
247export const STAGE_WORDS: Record<string, string> = {
248 defined: 'defined',
249 stamped: 'built',
250 lanes_passing: 'tested',
251 deployed: 'deployed',
252 released: 'released',
253}
254
255/** The stage after this one, or undefined at the last (or an unknown) stage. */
256export function nextStage(stage: string): string | undefined {
257 const at = STAGES.indexOf(stage as (typeof STAGES)[number])
258 return at < 0 ? undefined : STAGES[at + 1]
259}
260
261/** The open tickets that stand between a product and the given stage. */
262export function stageTickets(tickets: readonly Ticket[], productId: string, stage: string): Ticket[] {
263 return tickets.filter(t => t.product === productId && t.advancesStage === stage)
264}
265
266/** Cuts text to `width` cells with an ellipsis. */
267export function fit(text: string, width: number): string {
268 const room = Math.max(4, Math.floor(width))
269 return text.length <= room ? text : `${text.slice(0, room - 1)}…`
270}
271
272/** Whether a ticket passes the tickets tab's filter: "all", "p1", "p2", "p3" or "app:<id>". */
273export function ticketMatches(ticket: Ticket, filter: string): boolean {
274 if (filter === 'all' || filter === '') return true
275 if (/^p\d$/.test(filter)) return ticket.priority === Number(filter.slice(1))
276 if (filter.startsWith('app:')) return ticketsForApp([ticket], filter.slice(4)).length > 0
277 return true
278}
279
280/** A product-gate ticket's title without the "<product> <stage> gate: " lead the board already shows. */
281export function plainTitle(t: Ticket): string {
282 if (!t.product) return t.title
283 const lead = new RegExp(`^${t.product.replace(/[^a-z0-9_]/gi, '')}\\s+[a-z_]+\\s+gate:\\s*`, 'i')
284 const cut = t.title.replace(lead, '')
285 return cut ? cut.charAt(0).toUpperCase() + cut.slice(1) : t.title
286}
287
288/* ---- build cost and time: reports/mint/<app>.json, written by .claude/scripts/mint_report.py --all ------------------ */
289
290export const buildBasis = (v: unknown): BuildBasis | null => (v === 'own' || v === 'apportioned' ? v : null)
291
292const iso = (v: unknown): string => (typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : '')
293
294/** A mint report's summary; an older report without one reads as not measured throughout, never as 0. */
295export function parseBuild(raw: Json): Build {
296 const s: Json = raw?.summary ?? {}
297 return {
298 cost_usd: num(s.build_cost_usd),
299 basis: buildBasis(s.build_cost_basis),
300 uncounted_est_usd: num(s.build_cost_uncounted_est_usd),
301 agent_model_s: num(s.agent_model_s),
302 agent_tool_s: num(s.agent_tool_s),
303 active_s: num(s.active_s),
304 first_message: iso(s.first_message),
305 first_deploy: iso(s.first_deploy),
306 latest_deploy: iso(s.latest_deploy),
307 deploys: num(s.deploys),
308 calendar_to_first_deploy_s: num(s.calendar_to_first_deploy_s),
309 sessions: num(s.sessions),
310 shares: (Array.isArray(s.build_cost_shares) ? s.build_cost_shares : []).map((x: Json) => ({
311 session: String(x.session ?? ''),
312 basis: buildBasis(x.basis),
313 share: num(x.share),
314 })),
315 generated_at: String(raw?.generated_at ?? ''),
316 }
317}
318
319/** Sum of the numbers that were measured; null when none was (not measured, never 0). */
320const sumOf = (xs: readonly Measured[]): Measured => {
321 const got = xs.filter((x): x is number => x !== null)
322 return got.length ? got.reduce((a, b) => a + b, 0) : null
323}
324
325const earliest = (xs: readonly string[]): string => xs.filter(Boolean).sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? ''
326const latest = (xs: readonly string[]): string => xs.filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? ''
327
328/** A product's total: the sum of its apps' builds. Apportioned shares of one session add up without counting it twice,
329 * so the sum is honest; it is "apportioned" when any part is. Null when no app of it has a report. */
330export function sumBuilds(builds: readonly Build[]): Build | null {
331 if (builds.length === 0) return null
332 const first = earliest(builds.map(b => b.first_message))
333 const deployed = earliest(builds.map(b => b.first_deploy))
334 const gap = first && deployed ? (Date.parse(deployed) - Date.parse(first)) / 1000 : null
335 const bases = new Set(builds.map(b => b.basis).filter(Boolean))
336 return {
337 cost_usd: sumOf(builds.map(b => b.cost_usd)),
338 basis: bases.size === 0 ? null : bases.has('apportioned') ? 'apportioned' : 'own',
339 uncounted_est_usd: sumOf(builds.map(b => b.uncounted_est_usd)),
340 agent_model_s: sumOf(builds.map(b => b.agent_model_s)),
341 agent_tool_s: sumOf(builds.map(b => b.agent_tool_s)),
342 active_s: sumOf(builds.map(b => b.active_s)),
343 first_message: first,
344 first_deploy: deployed,
345 latest_deploy: latest(builds.map(b => b.latest_deploy)),
346 deploys: sumOf(builds.map(b => b.deploys)),
347 calendar_to_first_deploy_s: gap !== null && gap >= 0 ? gap : null,
348 sessions: new Set(builds.flatMap(b => b.shares.map(s => s.session))).size || sumOf(builds.map(b => b.sessions)),
349 shares: builds.flatMap(b => b.shares),
350 generated_at: latest(builds.map(b => b.generated_at)),
351 }
352}
353
354/** 300 -> "5m", 18720 -> "5h 12m", 187200 -> "2d 4h"; not measured -> "not measured". */
355export function duration(seconds: number | null): string {
356 if (seconds === null || !Number.isFinite(seconds)) return 'not measured'
357 const m = Math.max(0, Math.floor(seconds / 60))
358 if (m < 60) return `${m}m`
359 if (m < 24 * 60) return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
360 return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
361}
362
363/** One app's (or product's) build line as parts; `dim` parts are apportioned or estimated, and are shown dimmed so they
364 * never read as a figure measured for this app alone. */
365export function buildParts(b: Build | null | undefined): { text: string; dim: boolean }[] {
366 if (!b) return [{ text: 'built: not measured', dim: true }]
367 const shared = b.basis === 'apportioned'
368 const parts = [{ text: `built: ${money(b.cost_usd)}${b.cost_usd !== null && shared ? ' (shared)' : ''}`, dim: shared }]
369 if (b.uncounted_est_usd) parts.push({ text: `+ ~${money(b.uncounted_est_usd)} est.`, dim: true })
370 parts.push({ text: `· agent ${duration(b.agent_model_s)}`, dim: shared && b.agent_model_s !== null })
371 parts.push({ text: `· active ${duration(b.active_s)}`, dim: shared && b.active_s !== null })
372 parts.push({ text: `· first message → live ${duration(b.calendar_to_first_deploy_s)}`, dim: false })
373 parts.push({ text: b.deploys === null ? '· deploys not measured' : `· ${b.deploys} deploy${b.deploys === 1 ? '' : 's'}`, dim: false })
374 return parts
375}
376
377/* ---- uptime: .runs/uptime/state.json, written every minute by .claude/scripts/uptime.py ---------------------------- */
378
379/** The monitor counts as stopped when its last check is older than this. */
380export const UPTIME_STALE_MS = 5 * 60_000
381
382const UPTIME_STATUSES = new Set(['up', 'failing', 'down', 'unknown'])
383
384export function parseUptime(raw: Json | null | undefined): Uptime | null {
385 if (!raw || typeof raw !== 'object' || typeof raw.checked_at !== 'string') return null
386 const apps: UptimeApp[] = Object.entries((raw.apps ?? {}) as Record<string, Json>)
387 .map(([id, a]) => ({
388 id,
389 status: (UPTIME_STATUSES.has(String(a?.status)) ? String(a.status) : 'unknown') as UptimeApp['status'],
390 since: String(a?.since ?? a?.down_since ?? ''),
391 address: String(a?.address ?? ''),
392 lastError: String(a?.last_error ?? ''),
393 emailed: typeof a?.email?.sent === 'boolean' ? a.email.sent : null,
394 emailWhy: String(a?.email?.why ?? ''),
395 }))
396 .sort((x, y) => x.id.localeCompare(y.id))
397 return { checkedAt: raw.checked_at, apps, emailOn: raw.email?.configured === true, emailWhy: String(raw.email?.why ?? '') }
398}
399
400/** "35 min" / "2 h 5 min" between an ISO time and now. */
401export function since(iso: string, now: number): string {
402 const at = Date.parse(iso)
403 if (!Number.isFinite(at)) return '?'
404 const minutes = Math.max(0, Math.round((now - at) / 60_000))
405 if (minutes < 60) return `${minutes} min`
406 const h = Math.floor(minutes / 60)
407 return minutes % 60 ? `${h} h ${minutes % 60} min` : `${h} h`
408}
409
410const hhmm = (iso: string): string => {
411 const t = new Date(iso)
412 return Number.isFinite(t.getTime()) ? `${t.toISOString().slice(11, 16)} UTC` : '?'
413}
414
415/** The banner's lines: one per app that is down, in plain words; or a warning that the monitor stopped. */
416export function uptimeBanner(u: Uptime | null, now: number): UptimeBanner {
417 if (!u) return { level: 'missing', lines: ['Uptime monitor: not running yet (python3 .claude/scripts/uptime.py install).'] }
418 const down = u.apps.filter(a => a.status === 'down')
419 const lines = down.map(a => {
420 const told = a.emailed === true ? 'the operator was emailed' : a.emailed === false ? `not emailed: ${a.emailWhy}` : ''
421 return [`${a.id} is DOWN since ${hhmm(a.since)} (${since(a.since, now)})`, a.address, a.lastError, told].filter(Boolean).join(' · ')
422 })
423 const age = now - Date.parse(u.checkedAt)
424 const stale = !Number.isFinite(age) || age > UPTIME_STALE_MS
425 if (stale) lines.push(`Uptime monitor last ran ${since(u.checkedAt, now)} ago; it may have stopped (uptime.py status).`)
426 if (down.length) return { level: 'down', lines }
427 return stale ? { level: 'stale', lines } : { level: 'ok', lines: [] }
428}
429types/index.d.ts 178 lines1export type Check = { name: string; url: string; status: number | 'down' | 'pending' }
2
3export type AppRow = {
4 id: string
5 product: string
6 target: string
7 status: string
8 moldId: string
9 moldCommit: string
10 isCurrent: boolean
11 url: string
12 deployedAt: string
13 rls: string
14 checks: Check[]
15}
16
17export type ProductRow = { id: string; name: string; stage: string; apps: number; moldId: string; appIds: string[] }
18
19export type MoldRow = { id: string; status: string; snapshot: string; open: number; inProgress: number; done: number }
20
21export type Ticket = {
22 id: string
23 mold: string
24 title: string
25 status: string
26 priority: number
27 type?: string
28 owner?: string
29 product?: string
30 created?: string
31 updated?: string
32 advancesStage?: string
33 dependsOn?: string[]
34 acceptance?: string[]
35 evidence?: string[]
36 detail?: string
37 lane?: string
38}
39
40export type Board = {
41 root: string
42 loadedAt: number
43 checkedAt: number
44 apps: AppRow[]
45 products: ProductRow[]
46 molds: MoldRow[]
47 tickets: Ticket[]
48 error?: string
49}
50
51/** null: not measured (the report's not_measured says why); a number is a measured value, 0 included. */
52export type Measured = number | null
53
54/** How a cost was arrived at (app_usage.py): recorded by the app; estimated from tokens at the configured model; or both. */
55export type CostBasis = 'recorded' | 'estimated' | 'mixed'
56
57export type TicketCounts = { open: Measured; in_progress: Measured; done: Measured; total: Measured }
58
59export type UsageNumbers = {
60 people_active: Measured
61 chats: Measured
62 chat_turns: Measured
63 input_tokens: Measured
64 output_tokens: Measured
65 cost_usd: Measured
66 workflow_runs: Measured
67 workflow_cost_usd: Measured
68 /** Absent in an older report: read as recorded. */
69 workflow_cost_basis?: CostBasis | null
70 tickets: TicketCounts
71}
72
73export type AgentUsage = {
74 agent: string
75 kind: 'main' | 'specialist'
76 workspace: string
77 turns: Measured
78 runs: Measured
79 input_tokens: Measured
80 output_tokens: Measured
81 cost_usd: Measured
82 /** Absent in an older report: read as recorded. estimated / mixed: shown as an estimate, never as measured. */
83 cost_basis?: CostBasis | null
84 /** The model an estimate was priced at, when there is one. */
85 estimated_from?: string | null
86 last_active: string
87}
88
89export type UserUsage = {
90 user: string
91 workspace: string
92 chats: Measured
93 chat_turns: Measured
94 input_tokens: Measured
95 output_tokens: Measured
96 cost_usd: Measured
97 workflow_runs: Measured
98 last_active: string
99}
100
101export type Usage = {
102 app_id: string
103 generated_at: string
104 days: number
105 totals: UsageNumbers
106 workspaces: (UsageNumbers & { org_id: string; name: string })[]
107 daily: { date: string; chat_turns: Measured; people_active: Measured }[]
108 not_measured: string[]
109 by_agent: AgentUsage[]
110 by_user: UserUsage[]
111 error?: string
112}
113
114/** How an app's build figures were arrived at (mint_report.py): own, every session that names the app names no other;
115 * apportioned, a session shared with other apps was split and this is the app's share (shown as "shared", dimmed). */
116export type BuildBasis = 'own' | 'apportioned'
117
118/** What it took to build one app, or the sum over a product's apps: reports/mint/<app>.json's summary. */
119export type Build = {
120 /** Measured by the sessions' own cost counters (then apportioned when basis says so); null: not measured. */
121 cost_usd: Measured
122 basis: BuildBasis | null
123 /** Agent work after a session's last cost record: always an estimate, never added into cost_usd. */
124 uncounted_est_usd: Measured
125 agent_model_s: Measured
126 agent_tool_s: Measured
127 active_s: Measured
128 first_message: string
129 first_deploy: string
130 latest_deploy: string
131 deploys: Measured
132 calendar_to_first_deploy_s: Measured
133 sessions: Measured
134 /** Each session's share of it, when apportioned. */
135 shares: { session: string; basis: BuildBasis | null; share: Measured }[]
136 generated_at: string
137}
138
139export type Tab = 'products' | 'molds' | 'tickets' | 'analytics'
140
141/** One app in .runs/uptime/state.json, written every minute by .claude/scripts/uptime.py. */
142export type UptimeApp = {
143 id: string
144 /** up; failing: one failed check, not yet down; down: two or more in a row. */
145 status: 'up' | 'failing' | 'down' | 'unknown'
146 since: string
147 address: string
148 lastError: string
149 /** null: no email attempted; true: sent; false: not sent, `emailWhy` says why. */
150 emailed: boolean | null
151 emailWhy: string
152}
153
154export type Uptime = { checkedAt: string; apps: UptimeApp[]; emailOn: boolean; emailWhy: string }
155
156/** What the board shows about uptime: red while an app is down, amber when the monitor has not run lately. */
157export type UptimeBanner = { level: 'down' | 'stale' | 'ok' | 'missing'; lines: string[] }
158
159declare module 'claude-code' {
160 interface PluginState {
161 'factory-board': {
162 board: Board | null
163 isRefreshing: boolean
164 tab: Tab
165 selectedApp: string
166 usage: Record<string, Usage>
167 isCollecting: boolean
168 collectError: string
169 openTicket: string
170 ticketFilter: string
171 builds: Record<string, Build>
172 isComputing: boolean
173 computeError: string
174 uptime: Uptime | null
175 }
176 }
177}
178