SLOPSHOPPER

factory-board

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

newpanecommandstatusprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory-board
│ ┃ Factory board ✕ › fix the failing auth test and add an audit log call │ ┃ [ Products & apps ] [ Molds ] [ Tickets ] [ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Could not read /root/software-factory/state: ⎿ Read 6 lines │ ┃ ENOENT: no such file ⏺ Update(src/auth.ts) │ ┃ /root/software-factory/state/factory.json ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No apps in state/application. ⎿ 3 pass, 1 fail │ ┃ │ ┃ built: the AI the factory spent making it, ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ at list prices. (shared): split from │ ┃ sessions that also built other apps; ~ est.: ✻ Worked for 42s · done 4:20 PM │ ┃ work not yet counted, estimated. │ ┃ › /factory │ ┃ ⎿ factory-board: factory: 0/0 apps healthy · 0 open tickets │ ┃ │ ┃ [ Refreshing… ] [ Recompute build costs ] he │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ factory-board: factory: could not read state

Draws

Pane · Factory board
[ Products & apps ] [ Molds ] [ Tickets ] [ Analytics ] Could not read /root/software-factory/state: ENOENT: no such file /root/software-factory/state/factory.json No apps in state/application. built: the AI the factory spent making it, at list prices. (shared): split from sessions that also built other apps; ~ est.: work not yet counted, estimated. [ Refreshing… ] [ Recompute build costs ] health checked pen
README

factory-board

A Claude Code plugin that ships with the software factory. One pane, four tabs, covering every app the factory has built:

  • Products & apps: where each product stands, with its apps underneath: whether each is up right now, and where and when it was deployed.
  • Molds: the base codebases and their tickets.
  • Tickets: every open ticket, filterable, each one clickable.
  • Analytics: each app's rough usage, and the tickets people raised inside it.

Type /factory in a Claude Code session started in the factory.

The factory board open beside a Claude Code session

Tabs

The tab buttons sit at the top of the pane. You can also jump straight to a tab:

TypeOpens
/factory productsProducts & apps (the board opens here)
/factory moldsMolds
/factory ticketsTickets
/factory analytics onfinance_hfc_vmAnalytics, with that app picked
/factory mold_v1-215that ticket's details
/factory refreshthe board, after a fresh health check

Products & apps

Products & apps tab

Each product in state/products.json gets:

  • Its stage path: 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.
  • Each of its apps:
  • Health: live health (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.
  • Checks: the answer from each health page the board loads itself (web, api, and on Vercel the workflow service). Also the workspace-isolation proof from the last deploy: RLS 62/62 means all 62 workspace tables are locked to their workspace.
  • Deploy: when it was last deployed, and the base-code (mold) version it runs. current is the factory's latest snapshot; behind means a redeploy would update it.
  • Address: its web address.
  • Built: what it took to make the app, for example built: $1,299 (shared) + ~$37.62 est. · agent 16h 55m · active 12h 39m · first message → live 21h 33m · 70 deploys:
  • built: the AI (Claude) the factory spent making it, at list prices, read from the agent sessions' own cost counters. On a Claude subscription it is the value used, not an invoice.
  • (shared): most of the work happened in long sessions that built several apps. Such a session is split: each piece of work (from one message to the agent to the next) goes to the apps it names, in proportion to how often it names each, and a piece that names no app stays factory work, charged to no app. So two apps that share a session never both count all of it. A shared figure is dimmed.
  • ~ est.: work done after a session's last cost record, which the counter hasn't seen yet. It is estimated from the counted part, dimmed, and never added into the measured figure.
  • agent: the time the model spent working; active: session time with idle gaps over 5 minutes taken out. Both are shared the same way, and dimmed when they are.
  • first message → live: calendar time from the first message that named the app to its first deploy, waits included.
  • deploys: how many times it has been deployed, from git history.
  • A figure that can't be worked out says not measured, never 0.
  • The product's total, under its name: the sum of its apps' lines, marked (shared) when any part is.

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.

Molds

Molds tab

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.

Tickets

Tickets tab

  • Filters along the top: All, each priority, and each app that has tickets, with counts.
  • One line per ticket: a coloured priority (P1 red, P2 amber, P3 grey), its short number, the app it's about if any, and the title cut to fit.
  • Clicking: the title opens the ticket.

A ticket

A ticket shows:

  • its priority, status, app and full id;
  • the full title;
  • a line with type, lane, product, which stage it moves the product to, owner and date filed;
  • What happened, Done when (as checkboxes), Waits on (other open tickets, each clickable, and done ones ticked) and Evidence so far.

There are two buttons:

  • ← Back goes back to the list.
  • Work on this puts Work on factory ticket <id> … into your prompt, ready to send.

Analytics

Analytics tab, numbered

  1. Tabs.
  2. App picker: one button per app that isn't retired.
  3. Window: the last 30 days, and when the numbers were collected.
  4. Usage:
  5. people active, chats, chat turns;
  6. tokens in and out;
  7. estimated chat cost;
  8. workflow (helper) runs and their cost (dimmed and marked ~ est. when any of it is estimated).
  9. Chat turns per day: one bar per day, scaled to the busiest day.
  10. Tickets in the app: the tickets people raised inside the app itself (open, in progress, done). These are not the factory's tickets.
  11. By workspace: the same numbers for each workspace in the app.
  12. By agent and By user: the main agent's turns and each specialist's runs, with tokens and cost, and then each person's chats, turns, cost and when they were last active. People are shown by display name, never by email address. A figure that can't be worked out says not measured, never $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).
  13. Factory tickets for this app: open factory tickets that name this app. Each one is clickable.
  14. Buttons: Refresh checks health again. Collect usage pulls fresh numbers.

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.

Uptime alerts

.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.

  • An app is down after 2 failing checks in a row, so a single blip alerts nobody.
  • While any app is down, a red banner sits above the tabs on every tab. It names the app, its address, since when, what it answered, and whether the operator was emailed. The status line starts with ✕ DOWN: <app>.
  • If the monitor hasn't written anything for 5 minutes, an amber line says it may have stopped.
  • The board only reads the monitor's file, once a minute. It never sends anything itself.
  • Email: the operator gets an email when the app goes down, again every 30 minutes while it stays down, and when it's back up, with how long it was down. This needs the factory machine's own sending key (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.
  • The gap: the monitor runs on the factory machine, so it can't report that machine being down. The top of uptime.py lists free outside options and their trade-offs.

The status line

A one-line summary sits in the status line, so you can see it without opening the pane:

The summary in the status line

The board checks health every 5 minutes on its own.

Installing

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.

Developing it

  • 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.

Source 3 files
hooks/register.tsx 819 lines
1import { 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}
819
hooks/board.ts 429 lines
1import 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}
429
types/index.d.ts 178 lines
1export 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