SLOPSHOPPER

bs-mod

Blacksmith HUD: the bs epic, wave, agents, budget and what waits on you, live above the prompt.

newpanebandguardcommandtoast
★ 10v0.4.0MITupdated 2026-10-09juzser/blacksmith/mods/bs-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bs-mod
│ ┃ bs-mod ✕ › fix the failing auth test and add an audit log call │ ┃ No bs epic in this session yet; /bs-mod │ ┃ <epic-id> pins one. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bs-mod │ ⎿ bs-mod: No bs epic in this session yet; /bs-mod <epic-id> pins o │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── Overview Agents 0 in this session no running epic · /bs-mod <epic-id> pins one ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── Overview Agents 0 in this session no running epic · /bs-mod <epic-id> pins one
Pane · bs-mod
No bs epic in this session yet; /bs-mod <epic-id> pins one.
README

bs-mod

The live HUD for Blacksmith: what your epic is doing, above the Claude Code prompt. It is optional. Blacksmith runs the same without it.

/plugin marketplace add juzser/blacksmith
/plugin install bs-mod@blacksmith

bs-mod is a Claude Code plugin module, not skills or agents. It needs a Claude Code build that loads plugin modules. It was written and tested on 2.1.292, and no older build has been checked. claude plugin details bs-mod shows it once installed.

What it shows

  • The band sits above the prompt in every session. It starts with a blank row and a separator, then a row of tabs.
  • With an epic in view, the band has four tabs.
  • Overview shows active agents, tasks done, budget, the current wave or phase, and the epic's tier. Its tab and labels are soft white under Mocha and Latte, and the theme's grey otherwise.
  • Current shows the work in flight. One head row carries the wave or phase, a progress bar with tasks done, the live agents and the spend against the cap. Below it, a Tasks section lists the wave's tasks in aligned columns (status, id, title, role, time), and a Prompts section shows the two newest prompts you gave for it.
  • Next shows what comes next, with the same Tasks and Prompts sections.
  • Active work is marked in teal, in the bar, the tallies and the task marks.
  • Past shows what is done, grouped by the wave that merged it.
  • The letters o, c, n and p switch tabs while the band has focus.
  • With no epic, the band is idle. It shows this session's agent count, a Tasks row (the session task list, or the background work still running), the session's two newest prompts, and a hint to pin an epic.
  • The band stacks over other plugins' bands rather than hiding them.
  • The pane gives the full view of one epic.
  • Toasts report a wave admitted, a gate failure, a merge, a waiver pending, an escalation, an error above minor severity, a spec change proposed, and the epic closing. They come from this session's epics and a pinned one. The log a session starts on, and any event older than two minutes, never toasts, so opening a session on a long log stays quiet.
  • Colors. The band and the pane color by meaning in Catppuccin pastels: Mocha under a dark /config theme (dark-ansi too), Latte under a light one. A daltonized theme, auto, or a theme the HUD cannot read keeps the terminal theme's own colors, with active work in teal. Neutral body text stays in the theme's colors. The Overview tab, its labels and the Tasks and Prompts dividers take a soft white under Mocha and Latte, and the theme's inactive grey otherwise. Changing the theme repaints the HUD at once.
  • Closed epics. A closed epic leaves the band unless it is pinned.

The command

CommandDoes
/bs-modOpens the pane on the epic in view.
/bs-mod <epic-id>Pins that epic and opens its pane. It refuses an id with no event log under the known roots.
/bs-mod autoDrops the pin, so the HUD follows this session again.
/bs-mod off / /bs-mod onHides or shows the band.

Where it looks for event logs

It reads Blacksmith's state/events/*.jsonl directly, a few lines at a time. It looks in these directories:

  • $BS_HOME/state/events and $SMITH_HOME/state/events, each when the variable holds an absolute path.
  • <cwd>/state/events. A clone writes its logs into itself.
  • <cwd>/.blacksmith/state/events. This is where bs init puts a project's logs.
  • Roots learned from Bash commands the session runs. A command that sets BS_HOME/SMITH_HOME, or that runs a clone's factory/orchestrator/dist/cli.js, teaches the mod that root's state/events. It keeps up to 20 learned roots.

Developing it

Load this checkout's copy into one session. This installs nothing:

claude --plugin-dir "$PWD/mods/bs-mod"

From the repo root, three checks cover the mod:

claude plugin validate mods/bs-mod   # manifest, hooks.json, what register.tsx reads and writes
claude plugin test mods/bs-mod       # hooks/*.test.ts, in Claude Code's own test runner
pnpm exec tsc -p mods/bs-mod         # types; needs .claude-plugin/types/

Claude Code generates .claude-plugin/types/ when a session loads the mod: the engine's API types and the base tsconfig.json this folder extends. The folder is gitignored, so load the mod once with --plugin-dir before the type check.

scripts/check.sh runs the first two checks whenever claude is on PATH. CI has no claude, so there they print SKIP. The marketplace wiring is checked in CI anyway, by factory/orchestrator/test/pluginManifest.test.ts. The repo's vitest does not run this folder's tests, and Biome and the tsconfigs leave it out: its tests import Claude Code's own test kit, which only its runner provides. The repo-wide guards still read it, so its prose stays in English like the rest of the repo.

types/index.d.ts is the mod's own contract for the state it keeps. It is committed, unlike .claude-plugin/types/.

A release bumps .claude-plugin/plugin.json's version together with the package's. A test enforces that. An installed plugin updates only when that string changes.

Source 3 files
hooks/register.tsx 1349 lines
1// bs-mod: a band above the prompt, a pane and toasts for the Blacksmith epic
2// this CLI session drives. It reads the event logs itself, a few lines at a
3// time: the files of every epic one of whose events names this session, and of
4// the pinned epic. A closed epic of its own leaves the band unless it is pinned.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, RenderElement, RenderNode, TextProps } from 'claude-code'
7
8import type { BandTab, BgState, EpicView, Hud, PlanTier, TaskItem } from '../types'
9import {
10  activeSessionAgents,
11  bar,
12  bgEnd,
13  bgStart,
14  capped,
15  currentModel,
16  emptyHud,
17  EPIC_ERE,
18  epicOfFile,
19  epicsFromGrep,
20  fmtElapsed,
21  fmtTok,
22  foldEvent,
23  foldTaskTool,
24  latestPlanName,
25  nextModel,
26  notificationEnd,
27  overviewModel,
28  paletteOf,
29  parseBsRoots,
30  pastModel,
31  pickView,
32  planDirOf,
33  planEffort,
34  planVersionOf,
35  progressOf,
36  segments,
37  shortTask,
38  splitLines,
39  STATUS_RANK,
40  summarize,
41} from './fold'
42import type { BsEvent, Cells, Palette, PaletteRole, Phase, Progress, PromptRow, Spend, Summary, TaskRow, Tier } from './fold'
43
44const PANE = 'bs-mod'
45const TICK_MS = 4000
46const TOAST_MS = 6000
47/** an event older than this is history, never a toast */
48const FRESH_MS = 120_000
49const GREP_CHUNK = 200
50const ROOTS_CAP = 20
51const SEP = ' · '
52/** band bar cells: progress, budget */
53const BAND_BAR = 10
54const BAND_GAUGE = 8
55const PANE_BAR = 24
56const MAX_DOTS = 8
57/** the prompts Current and Next show, newest first; no `+N more` row for the rest */
58const BAND_PROMPTS = 2
59const BS_COMMAND = /\bbs\b|smith|cli\.js|BS_HOME|SMITH_HOME/
60const EPIC_ID = /^[A-Za-z0-9][\w.-]*$/
61
62const hudAtom = atom({ plugin: 'bs-mod', key: 'hud' } as const, emptyHud())
63const pinnedAtom = atom({ plugin: 'bs-mod', key: 'pinned' } as const, null)
64const hiddenAtom = atom({ plugin: 'bs-mod', key: 'isHidden' } as const, false)
65const minuteAtom = atom({ plugin: 'bs-mod', key: 'minute' } as const, 0)
66const planTiersAtom = atom({ plugin: 'bs-mod', key: 'planTiers' } as const, {} as Record<string, PlanTier>)
67/** the band's active tab, in `$.state` so a reload of the band keeps it; Overview until a tab is picked */
68/** The main loop's task list (TaskCreate, TaskUpdate, TaskList, TodoWrite), for the idle band's Tasks row. */
69const taskListAtom = atom({ plugin: 'bs-mod', key: 'taskList' } as const, [] as TaskItem[])
70/** The background shells and monitors the main loop started, and the ids of work that ended. */
71const bgAtom = atom({ plugin: 'bs-mod', key: 'bg' } as const, { started: {}, ended: [] } as BgState)
72const tabAtom = atom({ plugin: 'bs-mod', key: 'tab' } as const, 'overview' as BandTab)
73/** the `/config` theme, which picks the palette (fold.ts paletteOf); null until read, so the theme keys draw */
74const themeAtom = atom({ plugin: 'bs-mod', key: 'theme' } as const, null as string | null)
75
76type Entry = { path: string; name: string; size: number; mtimeMs: number }
77/** a folded line, with the epic of the file it came from */
78type Line = { ev: BsEvent; ref: string; ts: number; isNewFile: boolean; fileEpic: string | null }
79/** `color` and `bg`: a palette role (fold.ts PaletteRole, `active` the in-progress teal) or a neutral theme key */
80type Look = { color?: string; bg?: string; bold?: boolean; dim?: boolean }
81/** a stretch of text drawn in one style; `shrink`: the run a band row cuts first when it is too wide */
82type Run = Look & { text: string; shrink?: boolean }
83/** a piece of a band line; rank 0 always stays, the highest rank goes first */
84type Part = { runs: Run[]; rank: number }
85
86function basename(path: string): string {
87  return path.slice(path.lastIndexOf('/') + 1)
88}
89
90/** The event dirs a Bash command taught the mod, as kept in `$.store`. */
91function learned(value: unknown): string[] {
92  if (!Array.isArray(value)) return []
93  return value.filter((x): x is string => typeof x === 'string' && x.startsWith('/')).slice(0, ROOTS_CAP)
94}
95
96function plural(n: number, word: string): string {
97  return `${n} ${word}${n === 1 ? '' : 's'}`
98}
99
100/** A palette role in the theme's palette; a neutral theme key stays itself. */
101function paint(pal: Palette, color: string): string {
102  return Object.hasOwn(pal, color) ? pal[color as PaletteRole] : color
103}
104
105function style(p: Look, pal: Palette): TextProps {
106  const out: TextProps = {}
107  if (p.color) out.color = paint(pal, p.color)
108  if (p.bg) out.backgroundColor = paint(pal, p.bg)
109  if (p.bold) out.bold = true
110  if (p.dim) out.dimColor = true
111  return out
112}
113
114function run(text: string, look: Look = {}): Run {
115  return { text, ...look }
116}
117
118function part(rank: number, ...runs: Run[]): Part {
119  return { runs: runs.filter(r => r.text), rank }
120}
121
122function partWidth(p: Part): number {
123  return p.runs.reduce((n, r) => n + r.text.length, 0)
124}
125
126/** Each role keeps one palette color, wherever it is drawn; verifier and scribe keep their theme keys. */
127const ROLE_COLOR: Record<string, string> = {
128  coder: 'claude',
129  tester: 'planMode',
130  reviewer: 'ide',
131  'spec-reviewer': 'ide',
132  verifier: 'bashBorder',
133  grader: 'autoAccept',
134  'security-reviewer': 'error',
135  uiux: 'merged',
136  researcher: 'permission',
137  planner: 'permission',
138  merger: 'success',
139  'wave-runner': 'suggestion',
140  scribe: 'subtle',
141}
142
143function roleColor(role: string): string {
144  return ROLE_COLOR[role] ?? 'suggestion'
145}
146
147function budgetColor(pct: number): string {
148  if (pct >= 90) return 'error'
149  if (pct >= 70) return 'warning'
150  return 'success'
151}
152
153const FINDING_COLOR: Record<string, string> = {
154  raised: 'error',
155  confirmed: 'error',
156  'amend-pending': 'warning',
157  'fix-pending': 'claude',
158  'fix-landed': 'ide',
159}
160
161function chip(epicId: string): Run {
162  return run(` ${epicId} `, { bg: 'claude', color: 'inverseText', bold: true })
163}
164
165/** The progress bar, one color per status: done, review, active, todo. */
166function barRuns(c: Cells): Run[] {
167  return [
168    run('█'.repeat(c.done), { color: 'success' }),
169    run('█'.repeat(c.review), { color: 'ide' }),
170    run('█'.repeat(c.active), { color: 'active' }),
171    run('░'.repeat(c.todo), { color: 'inactive' }),
172  ].filter(r => r.text)
173}
174
175/** The budget gauge, `width` cells filled to `pct` in the budget tone. */
176function gaugeRuns(pct: number, width: number): Run[] {
177  const full = Math.min(width, Math.max(0, Math.round((pct / 100) * width)))
178  return [run('▰'.repeat(full), { color: budgetColor(pct) }), run('▱'.repeat(width - full), { color: 'inactive' })].filter(r => r.text)
179}
180
181/** The budget gauge and its numbers, green, then warning at 70%, error at 90%. */
182function budgetRuns(b: NonNullable<Summary['budget']>, width: number): Run[] {
183  const tone = budgetColor(b.pct)
184  return [
185    ...gaugeRuns(b.pct, width),
186    run(` ${fmtTok(b.projected)}/${fmtTok(b.cap)}`),
187    run(' tok ', { dim: true }),
188    run(`${b.pct}%`, { color: tone, bold: true }),
189  ].filter(r => r.text)
190}
191
192/** Status counts after the bar: review, active, todo, each in its bar color. */
193function tallyParts(c: Summary['counts']): Part[] {
194  const out: Part[] = []
195  if (c.review) out.push(part(3, run(`◐ ${c.review} review`, { color: 'ide' })))
196  if (c.active) out.push(part(3, run(`● ${c.active} active`, { color: 'active' })))
197  if (c.todo) out.push(part(4, run(`○ ${c.todo} todo`, { dim: true })))
198  return out
199}
200
201/** Drops the highest-ranked parts, the last of a rank first, until the rest fit. */
202function fit(parts: Part[], width: number): Part[] {
203  const kept = [...parts]
204  const len = () => kept.reduce((n, p, i) => n + partWidth(p) + (i ? SEP.length : 0), 0)
205  while (len() > width) {
206    let drop = -1
207    let dropRank = 0
208    kept.forEach((p, i) => {
209      if (p.rank > 0 && p.rank >= dropRank) {
210        drop = i
211        dropRank = p.rank
212      }
213    })
214    if (drop < 0) break
215    kept.splice(drop, 1)
216  }
217  return kept
218}
219
220function stateRun(s: Summary): Run {
221  if (s.isClosed) return run('✔ closed', { color: 'success', bold: true })
222  if (s.now) return run('● running', { color: 'claude', bold: true })
223  if (s.isWaitingOnYou) return run('⚑ waiting on you', { color: 'warning', bold: true })
224  return run('idle', { dim: true })
225}
226
227function statusOf(s: Summary): string {
228  return `${s.epicId}${SEP}w${s.wave}${SEP}${s.counts.done}/${s.counts.total}`
229}
230
231function glyph(status: string): Look & { mark: string } {
232  switch (status) {
233    case 'blocked':
234    case 'failed':
235      return { mark: '✖', color: 'error' }
236    case 'escalated':
237      return { mark: '⚑', color: 'warning' }
238    case 'in-progress':
239      return { mark: '●', color: 'active' }
240    case 'reviewing':
241    case 'merging':
242      return { mark: '◐', color: 'ide' }
243    case 'completed':
244      return { mark: '✔', color: 'success' }
245    // landed without a merge: done, like Blacksmith queries.ts statusBucketForTaskStatus counts it, drawn a step quieter
246    case 'waived':
247      return { mark: '✔', color: 'success', dim: true }
248    case 'superseded':
249      return { mark: '–', dim: true }
250    default:
251      return { mark: '○', dim: true }
252  }
253}
254
255function taskNumber(id: string): number {
256  const m = /task-(\d+)/.exec(id)
257  return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER
258}
259
260// The fold titles a row no task-added named with its own id, so a title that
261// is the id (full or short) is no title: the row already draws the id.
262function titleText(id: string, title: string): string {
263  return title === id || title === shortTask(id) ? '' : title
264}
265
266function toneColor(tone: string): string | undefined {
267  if (tone === 'bad') return 'error'
268  if (tone === 'warn') return 'warning'
269  if (tone === 'ok') return 'success'
270  return undefined
271}
272
273// ── The band: a rule, a tab row and the active tab's rows, each row cut to bodyColumns ─────────
274
275/**
276 * The band's tabs in the tab row's order: label, hotkey and accent (the chip, the rule and the header). The
277 * hotkeys are letters: a bare digit typed into an empty composer presses a band Button (ButtonProps.hotkey), so a
278 * digit would switch tabs from the prompt; a letter fires only while the band holds the focus.
279 */
280type TabSpec = { id: BandTab; label: string; hotkey: string; accent: string }
281const OVERVIEW_TAB: TabSpec = { id: 'overview', label: 'Overview', hotkey: 'o', accent: 'label' }
282const TABS: readonly TabSpec[] = [
283  OVERVIEW_TAB,
284  { id: 'current', label: 'Current', hotkey: 'c', accent: 'permission' },
285  { id: 'next', label: 'Next', hotkey: 'n', accent: 'planMode' },
286  { id: 'past', label: 'Past', hotkey: 'p', accent: 'success' },
287]
288const TAB_GAP = 2
289/** the columns a plain Button adds to its label: the hotkey, a colon and a space (`c: Current`) */
290const PLAIN_HOTKEY_W = 3
291/** the columns a Button with chrome adds to its label: `[ Details ]` */
292const BUTTON_CHROME_W = 4
293/** the band's row labels pad to this, so the bars line up */
294const LABEL_W = 8
295const TIER_COLOR: Record<Tier, string> = { small: 'success', medium: 'ide', huge: 'autoAccept' }
296const PHASE_COLOR: Record<Phase['kind'], string> = {
297  planning: 'planMode',
298  wave: 'permission',
299  after: 'permission',
300  closing: 'merged',
301  closed: 'success',
302}
303
304/** One band row: its key (the test kit and the bodyColumns check find it by it) and its runs, cut to fit when drawn. */
305type BandRow = { key: string; runs: Run[] }
306
307/** Columns, counted per code point so a cut never splits a surrogate pair; a wide glyph still counts one. */
308function cols(text: string): number {
309  return [...text].length
310}
311
312function cut(text: string, n: number): string {
313  return [...text].slice(0, Math.max(0, n)).join('')
314}
315
316function runsWidth(runs: readonly Run[]): number {
317  return runs.reduce((n, r) => n + cols(r.text), 0)
318}
319
320/** The run a row cuts first when it is too wide: a title, a prompt. */
321function shrink(r: Run): Run {
322  return { ...r, shrink: true }
323}
324
325/** Cuts a row to `width` columns, ending it in `…`: the shrink run gives way first, then the tail. */
326function clip(runs: readonly Run[], width: number): Run[] {
327  if (width < 1) return []
328  const out = runs.filter(r => r.text)
329  const over = runsWidth(out) - width
330  if (over <= 0) return out
331  const i = out.findIndex(r => r.shrink)
332  const give = out[i]
333  const keep = give ? cols(give.text) - over - 1 : 0
334  if (give && keep > 0) {
335    out[i] = { ...give, text: `${cut(give.text, keep).trimEnd()}…` }
336    return out
337  }
338  const kept: Run[] = []
339  let room = width - 1
340  for (const r of out) {
341    if (cols(r.text) <= room) {
342      kept.push(r)
343      room -= cols(r.text)
344      continue
345    }
346    kept.push({ ...r, text: `${cut(r.text, room).trimEnd()}…` })
347    break
348  }
349  return kept
350}
351
352/** Parts joined by a separator, after `fit` dropped what the width cannot hold. */
353function joinParts(parts: Part[], width: number): Run[] {
354  return fit(parts, width).flatMap((p, i) => (i > 0 ? [run(SEP, { color: 'subtle' }), ...p.runs] : p.runs))
355}
356
357/** A row label in the tab's accent, padded so the bars line up. */
358function label(text: string, accent: string): Run[] {
359  return [run(text, { color: accent, bold: true }), run(' '.repeat(Math.max(1, LABEL_W - text.length)))]
360}
361
362function num(n: number, look: Look = {}): Run {
363  return run(String(n), { ...look, bold: true })
364}
365
366function dots(roles: readonly string[]): Run[] {
367  return roles.slice(0, MAX_DOTS).map(r => run('●', { color: roleColor(r) }))
368}
369
370/** The band's status tallies after the bar, each number bold in its bar color. */
371function bandTallies(c: Cells): Part[] {
372  const out: Part[] = []
373  if (c.review) out.push(part(3, run('◐ ', { color: 'ide' }), num(c.review, { color: 'ide' }), run(' review', { color: 'ide' })))
374  if (c.active) out.push(part(3, run('● ', { color: 'active' }), num(c.active, { color: 'active' }), run(' active', { color: 'active' })))
375  if (c.todo) out.push(part(4, run('○ ', { color: 'inactive' }), num(c.todo, { color: 'inactive' }), run(' todo', { color: 'inactive' })))
376  return out
377}
378
379/**
380 * A spend row: the label, the gauge in the budget tone, `projected / cap` and the percent; `projected` left out
381 * when the row is too narrow for it.
382 */
383function spendRow(key: string, name: string, accent: string, b: Spend, width: number): BandRow {
384  const tone = budgetColor(b.pct)
385  const runs = (basis: string) => [
386    ...label(name, accent),
387    ...gaugeRuns(b.pct, BAND_GAUGE),
388    run(' '),
389    run(`${fmtTok(b.projected)} / ${fmtTok(b.cap)}`, { color: tone, bold: true }),
390    run(basis, { color: 'subtle' }),
391    run(' '),
392    run(`${b.pct}%`, { color: tone, bold: true }),
393  ]
394  const full = runs(' projected')
395  return { key, runs: runsWidth(full) <= width ? full : runs('') }
396}
397
398function oneLine(text: string): string {
399  return text.replace(/\s+/g, ' ').trim()
400}
401
402/** A task row: the status mark, the short id, the title (none when it would repeat the id), the role working it and since when. */
403function taskRuns(t: TaskRow): Run[] {
404  const g = glyph(t.status)
405  const runs = [run(g.mark, { color: g.color ?? 'inactive' }), run(' '), run(t.short, { bold: true })]
406  if (t.title) runs.push(run(' '), shrink(run(oneLine(t.title))))
407  if (t.role) runs.push(run(' '), run(t.role, { color: roleColor(t.role), bold: true }))
408  if (t.elapsed) runs.push(run(' '), run(t.elapsed, { dim: true }))
409  return runs
410}
411
412/**
413 * An operator prompt, apart from the task rows: `❝`, how long ago, the text on one line. `ago`: Past's `5m ago `;
414 * Current's and Next's Prompts sections draw the bare age and two spaces.
415 */
416function promptRuns(p: PromptRow, now: number, ago = true): Run[] {
417  const age = fmtElapsed(Math.max(0, now - p.ts))
418  return [
419    run('❝ ', { color: 'remember' }),
420    run(ago ? `${age} ago` : age, { dim: true }),
421    run(ago ? ' ' : '  '),
422    shrink(run(oneLine(p.text), { color: 'remember' })),
423    // Current's and Next's rows name the task the prompt led to; Past groups them by wave already
424    ...(!ago && p.task ? [run(` → ${p.task}${p.more ? ` +${p.more}` : ''}`, { dim: true })] : []),
425  ]
426}
427
428function moreRow(key: string, n: number, what = 'more'): BandRow {
429  return { key, runs: [run(`+${n} ${what}`, { dim: true })] }
430}
431
432/**
433 * How many task rows and prompts fit `budget` rows, each list followed by a `+N more` row when it is cut: a task,
434 * a second task, a prompt, then the rest of the tasks, then the rest of the prompts.
435 */
436function allot(budget: number, tasks: number, prompts: number): [number, number] {
437  const total: [number, number] = [tasks, prompts]
438  const n: [number, number] = [0, 0]
439  const cost = () => n[0] + (n[0] < tasks ? 1 : 0) + n[1] + (n[1] < prompts ? 1 : 0)
440  const wants: [0 | 1, number][] = [[0, 1], [0, 2], [1, 1], [0, tasks], [1, prompts]]
441  for (const [i, target] of wants) {
442    while (n[i] < Math.min(target, total[i])) {
443      n[i] += 1
444      if (cost() > budget) {
445        n[i] -= 1
446        break
447      }
448    }
449  }
450  return n
451}
452
453/** A task list and its prompts in `budget` rows, each cut list ending in a dim `+N more`. */
454function listRows(tasks: readonly TaskRow[], prompts: readonly PromptRow[], budget: number, now: number, tag = ''): BandRow[] {
455  const [t, p] = allot(budget, tasks.length, prompts.length)
456  // not even one row and its `+N more`: one count for both lists
457  if (t === 0 && p === 0) return budget >= 1 && tasks.length + prompts.length > 0 ? [moreRow(`more${tag}`, tasks.length + prompts.length)] : []
458  const shown = capped(tasks, t)
459  const asked = capped(prompts, p)
460  const rows: BandRow[] = shown.rows.map(r => ({ key: `task:${r.id}`, runs: taskRuns(r) }))
461  if (shown.more) rows.push(moreRow(`more:tasks${tag}`, shown.more))
462  rows.push(...asked.rows.map(r => ({ key: `prompt:${r.ref}`, runs: promptRuns(r, now) })))
463  if (asked.more) rows.push(moreRow(`more:prompts${tag}`, asked.more))
464  return rows.slice(0, Math.max(0, budget))
465}
466
467/** The head row of Next and Past: the header in the tab's accent. */
468function headRow(key: string, header: string, accent: string): BandRow {
469  return { key, runs: [run(header, { color: accent, bold: true })] }
470}
471
472/** A section divider of Current and Next, `── Tasks ───…`, as wide as the band like the rule. */
473function sectionRow(key: string, name: string, width: number): BandRow {
474  return {
475    key,
476    runs: [
477      run('── ', { color: 'subtle' }),
478      run(name, { color: 'label', bold: true }),
479      run(` ${'─'.repeat(Math.max(0, width - 3 - cols(name) - 1))}`, { color: 'subtle' }),
480    ],
481  }
482}
483
484/** `text` in at most `n` columns, ending in `…` when it was cut. */
485function ellipsize(text: string, n: number): string {
486  if (cols(text) <= n) return text
487  return n < 1 ? '' : `${cut(text, n - 1).trimEnd()}…`
488}
489
490function spaces(n: number): Run {
491  return run(' '.repeat(Math.max(0, n)))
492}
493
494/**
495 * A task list in columns: the mark, the short id padded to the widest, the title cut with `…` to the room left and
496 * padded, then, when a row has them, the role padded to the widest and the elapsed time. With no role or time in the
497 * list the title runs to the edge; no row is wider than `width`.
498 */
499function columnRows(tasks: readonly TaskRow[], width: number): BandRow[] {
500  const widest = (f: (t: TaskRow) => string) => Math.max(0, ...tasks.map(t => cols(f(t))))
501  const idW = widest(t => t.short)
502  const roleW = widest(t => t.role ?? '')
503  const timeW = widest(t => t.elapsed ?? '')
504  const right = (roleW ? 2 + roleW : 0) + (timeW ? 2 + timeW : 0)
505  const titleW = Math.min(widest(t => oneLine(t.title)), Math.max(0, width - 2 - idW - 2 - right))
506  return tasks.map(t => {
507    const g = glyph(t.status)
508    const runs: Run[] = [run(g.mark, { color: g.color ?? 'inactive' }), run(' '), run(t.short, { bold: true }), spaces(idW - cols(t.short))]
509    if (titleW > 0) {
510      const title = ellipsize(oneLine(t.title), titleW)
511      runs.push(run('  '), shrink(run(title)), spaces(titleW - cols(title)))
512    }
513    if (roleW) runs.push(run('  '), run(t.role ?? '', { color: roleColor(t.role ?? ''), bold: true }), spaces(roleW - cols(t.role ?? '')))
514    if (timeW) runs.push(run('  '), run(t.elapsed ?? '', { dim: true }))
515    // a row with no role or time ends at its title
516    while (runs.at(-1)?.text.trim() === '') runs.pop()
517    return { key: `task:${t.id}`, runs }
518  })
519}
520
521/**
522 * How many tasks and prompts Current's and Next's sections show in `budget` rows: a task, a second task, a prompt, a
523 * second prompt, then the rest of the tasks. A section costs its divider, a cut task list its `+N more`. With tasks
524 * to show and not one that fits, no section shows: the prompts never stand in for the tasks.
525 */
526function allotSections(budget: number, tasks: number, prompts: number): [number, number] {
527  const total: [number, number] = [tasks, prompts]
528  const n: [number, number] = [0, 0]
529  const cost = () => (n[0] ? 1 + n[0] + (n[0] < tasks ? 1 : 0) : 0) + (n[1] ? 1 + n[1] : 0)
530  const wants: [0 | 1, number][] = [[0, 1], [0, 2], [1, 1], [1, 2], [0, tasks]]
531  for (const [i, target] of wants) {
532    while (n[i] < Math.min(target, total[i])) {
533      n[i] += 1
534      if (cost() > budget) {
535        n[i] -= 1
536        break
537      }
538    }
539  }
540  return tasks > 0 && n[0] === 0 ? [0, 0] : n
541}
542
543/** Current's and Next's lists in `budget` rows: a Tasks section and its `+N more` when cut, then a Prompts section. */
544function sectionRows(tasks: readonly TaskRow[], prompts: readonly PromptRow[], budget: number, width: number, now: number): BandRow[] {
545  const [t, p] = allotSections(budget, tasks.length, prompts.length)
546  // not even one section: one count for both lists
547  if (t === 0 && p === 0) return budget >= 1 && tasks.length + prompts.length > 0 ? [moreRow('more', tasks.length + prompts.length)] : []
548  const rows: BandRow[] = []
549  if (t > 0) {
550    const shown = capped(tasks, t)
551    rows.push(sectionRow('section:tasks', 'Tasks', width), ...columnRows(shown.rows, width))
552    if (shown.more) rows.push(moreRow('more:tasks', shown.more))
553  }
554  if (p > 0) {
555    rows.push(sectionRow('section:prompts', 'Prompts', width))
556    rows.push(...prompts.slice(0, p).map(r => ({ key: `prompt:${r.ref}`, runs: promptRuns(r, now, false) })))
557  }
558  return rows.slice(0, Math.max(0, budget))
559}
560
561type BandInput = { epic: EpicView; now: number; rows: number; width: number; effort: Tier | null; sessionAgents: number }
562
563/** The Overview's `Agents N in this session`: the idle band draws the same part. */
564function sessionAgentsPart(n: number, accent: string): Part {
565  return part(0, ...label('Agents', accent), num(n), run(' in this session'))
566}
567
568/** The active tab: an inverse badge in its accent. */
569function badge(t: TabSpec): Run {
570  return run(` ${t.label} `, { bg: t.accent, color: 'inverseText', bold: true })
571}
572
573/** The band's rule, in the active tab's accent. */
574function rule(width: number, accent: string): Run {
575  return run('─'.repeat(width), { color: accent })
576}
577
578/** What the band holds in `rows`: a blank row, then the rule, from three rows; the rule from two; the tab row
579 * always; the body what is left. Short rows clip the body first, then the blank row; the rule and the tab row go
580 * last, the rule first. */
581function bandFit(rows: number): { blank: boolean; rule: boolean; body: number } {
582  if (rows >= 3) return { blank: true, rule: true, body: rows - 3 }
583  return { blank: false, rule: rows === 2, body: 0 }
584}
585
586const IDLE_LINE = `no running epic${SEP}/bs-mod <epic-id> pins one`
587
588/** The idle band's body, with no epic in view: the session's live agents, styled as the Overview's, then a dim line. */
589function idleRows(sessionAgents: number, progress: Progress | null, prompts: readonly PromptRow[], now: number, width: number): BandRow[] {
590  const rows: BandRow[] = [{ key: 'agents', runs: joinParts([sessionAgentsPart(sessionAgents, OVERVIEW_TAB.accent)], width) }]
591  if (progress) rows.push({ key: 'progress', runs: joinParts(progressParts(progress, OVERVIEW_TAB.accent), width) })
592  for (const p of prompts) rows.push({ key: `prompt:${p.ref}`, runs: promptRuns(p, now) })
593  rows.push({ key: 'idle', runs: [run(IDLE_LINE, { dim: true })] })
594  return rows
595}
596
597/** The Tasks row: `2/5 done · ▸ Running tests` for a task list, `2 running · 9 done · 1 agent, 1 shell` for background work. */
598function progressParts(p: Progress, accent: string): Part[] {
599  if (p.kind === 'list') {
600    return [
601      part(0, ...label('Tasks', accent), num(p.done), run(`/${p.total} done`)),
602      ...(p.doing ? [part(1, run('▸ ', { color: accent }), shrink(run(oneLine(p.doing))))] : []),
603    ]
604  }
605  const kinds = [
606    ...(p.agents ? [plural(p.agents, 'agent')] : []),
607    ...(p.shells ? [plural(p.shells, 'shell')] : []),
608    ...(p.monitors ? [plural(p.monitors, 'monitor')] : []),
609  ].join(', ')
610  return [
611    part(0, ...label('Tasks', accent), num(p.running), run(' running')),
612    part(0, num(p.done), run(' done')),
613    ...(kinds ? [part(1, run(kinds))] : []),
614  ]
615}
616
617/**
618 * Whether a tree from beneath draws anything. `next(e)` always answers an element: with no plugin beneath
619 * bs-mod, core's `{ type: 'engine' }`, its own AbovePrompt, which draws a survey (passed through before the band
620 * draws) and else nothing. An empty Box or Text draws nothing either.
621 */
622function drawable(node: RenderNode | null | undefined): boolean {
623  if (node === null || node === undefined) return false
624  if (typeof node === 'string') return node.length > 0
625  if (node.type === 'engine') return false
626  if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).some(drawable)
627  return true
628}
629
630function overviewRows(b: BandInput): BandRow[] {
631  const m = overviewModel(b.epic, b.now, b.effort)
632  const accent = OVERVIEW_TAB.accent
633  const head = [part(0, chip(m.epicId)), part(0, run(m.phase.label, { color: PHASE_COLOR[m.phase.kind], bold: true }))]
634  if (m.tier.tier) head.push(part(1, run(`tier ${m.tier.tier}`, { color: TIER_COLOR[m.tier.tier], bold: true })))
635  const agents = [
636    sessionAgentsPart(b.sessionAgents, accent),
637    part(1, ...dots(m.agents.roles), run(m.agents.roles.length ? ' ' : ''), num(m.agents.count), run(' in the epic')),
638  ]
639  const c = m.tasks
640  const tasks = [
641    part(0, ...label('Tasks', accent), ...barRuns(segments(c, BAND_BAR)), run(' '), run(`${c.done}/${c.total} done`, { color: 'success', bold: true })),
642    ...bandTallies(c),
643  ]
644  const rows: BandRow[] = [
645    { key: 'head', runs: joinParts(head, b.width) },
646    { key: 'agents', runs: joinParts(agents, b.width) },
647    { key: 'tasks', runs: joinParts(tasks, b.width) },
648  ]
649  if (m.budget) rows.push(spendRow('budget', 'Budget', accent, m.budget, b.width))
650  return rows
651}
652
653/**
654 * Current: one head row (the phase, the wave bar and done/total, the live agents on the wave, the spend),
655 * then a Tasks and a Prompts section. A narrow band drops the spend first, then the agents.
656 */
657function currentRows(b: BandInput): BandRow[] {
658  const m = currentModel(b.epic, b.now, { prompts: BAND_PROMPTS })
659  const accent = 'permission'
660  const w = m.wave
661  const progress = w ? [run('  '), ...barRuns(segments(w, BAND_BAR)), run(' '), run(`${w.done}/${w.total}`, { color: 'success', bold: true })] : []
662  const head = [part(0, run(m.header, { color: accent, bold: true }), ...progress)]
663  if (m.agents.count > 0) head.push(part(2, ...dots(m.agents.roles), run(' '), num(m.agents.count)))
664  if (m.tokens) {
665    const tone = budgetColor(m.tokens.pct)
666    head.push(part(3, run(`${fmtTok(m.tokens.projected)}/${fmtTok(m.tokens.cap)}`, { color: tone }), run(' '), run(`${m.tokens.pct}%`, { color: tone, bold: true })))
667  }
668  const empty = m.tasks.rows.length === 0 && m.prompts.rows.length === 0
669  const lists = empty
670    ? [{ key: 'empty', runs: [run('nothing running', { color: 'inactive' })] }]
671    : sectionRows(m.tasks.rows, m.prompts.rows, b.rows - 1, b.width, b.now)
672  return [{ key: 'head', runs: joinParts(head, b.width) }, ...lists]
673}
674
675function nextRows(b: BandInput): BandRow[] {
676  const m = nextModel(b.epic, b.now, { prompts: BAND_PROMPTS })
677  const head = headRow('head', m.header, 'planMode')
678  if (m.tasks.rows.length === 0) return [head, { key: 'empty', runs: [run('nothing planned', { color: 'inactive' })] }]
679  return [head, ...sectionRows(m.tasks.rows, m.prompts.rows, b.rows - 1, b.width, b.now)]
680}
681
682function pastRows(b: BandInput): BandRow[] {
683  const groups = pastModel(b.epic, b.now).rows
684  if (groups.length === 0) return [{ key: 'empty', runs: [run('nothing done yet', { color: 'inactive' })] }]
685  const rows: BandRow[] = []
686  for (const [i, g] of groups.entries()) {
687    const later = groups.length - i - 1
688    // a row for `+N more waves` while a later wave waits; a head and one row at least for this one
689    const room = b.rows - rows.length - (later > 0 ? 1 : 0)
690    if (room < 2 && i > 0) {
691      const left = groups.length - i
692      rows.push(moreRow('more:groups', left, left === 1 ? 'more wave' : 'more waves'))
693      break
694    }
695    rows.push(i === 0 ? headRow('head', g.label, 'success') : { key: `group:${g.label}`, runs: [run(g.label, { color: 'success', bold: true })] })
696    rows.push(...listRows(g.tasks.rows, g.prompts.rows, room - 1, b.now, `:${g.label}`))
697  }
698  return rows
699}
700
701/** A key once per drawing: a prompt that asked for work of two waves shows under each. */
702function uniqueKeys(rows: BandRow[]): BandRow[] {
703  const seen = new Map<string, number>()
704  return rows.map(r => {
705    const n = (seen.get(r.key) ?? 0) + 1
706    seen.set(r.key, n)
707    return n === 1 ? r : { ...r, key: `${r.key}#${n}` }
708  })
709}
710
711/** The active tab's rows, at most `rows` of them. */
712function tabRows(tab: BandTab, b: BandInput): BandRow[] {
713  const rows = tab === 'current' ? currentRows(b) : tab === 'next' ? nextRows(b) : tab === 'past' ? pastRows(b) : overviewRows(b)
714  return uniqueKeys(rows.slice(0, Math.max(0, b.rows)))
715}
716
717/** What one load of the module knows; `register` makes it, each hook passes it on. */
718type State = {
719  hud: Hud
720  /** per log file: complete lines folded, and the size they were read at (-1: read again) */
721  cursors: Map<string, { lines: number; size: number }>
722  /** per log file not yet known to be ours: the size grep last searched */
723  scanned: Map<string, number>
724  /** per log file: the epic its content names, kept for good (a file's epic never changes) */
725  fileEpic: Map<string, string>
726  /** per log file whose content names no epic: the size grep last searched */
727  probed: Map<string, number>
728  /** epics one of whose events names this CLI session */
729  mine: Set<string>
730  maxTs: number
731  isBooted: boolean
732  running: Promise<void> | null
733  lastStatus: string | undefined
734  lastMinute: number
735  /** the epic of each log file the last tick listed */
736  lastEpics: string[]
737  lastRoots: string[]
738  /** per session and cwd: the events dir `bs-prompt-hook --resolve` named, or null for none (asked once) */
739  resolved: Map<string, string | null>
740  lastError: string
741  timer: { cancel(): void } | null
742}
743
744function newState(): State {
745  return {
746    hud: emptyHud(),
747    cursors: new Map(),
748    scanned: new Map(),
749    fileEpic: new Map(),
750    probed: new Map(),
751    mine: new Set(),
752    maxTs: 0,
753    isBooted: false,
754    running: null,
755    lastStatus: undefined,
756    lastMinute: -1,
757    lastEpics: [],
758    lastRoots: [],
759    resolved: new Map(),
760    lastError: '',
761    timer: null,
762  }
763}
764
765function resetFold(st: State): void {
766  st.hud = emptyHud()
767  st.cursors.clear()
768  st.maxTs = 0
769}
770
771function messageOf(err: unknown): string {
772  return err instanceof Error ? err.message : String(err)
773}
774
775/** The event dirs of this session itself: its BS_HOME / SMITH_HOME and its cwd. */
776async function ownRoots($: EngineInterface): Promise<string[]> {
777  const cwd = (await $.session.cwd()).replace(/\/$/, '')
778  const out = new Set<string>()
779  for (const home of [await $.env.get('BS_HOME'), await $.env.get('SMITH_HOME')]) {
780    if (home && home.startsWith('/')) out.add(`${home.replace(/\/$/, '')}/state/events`)
781  }
782  out.add(`${cwd}/state/events`)
783  out.add(`${cwd}/.blacksmith/state/events`)
784  return [...out]
785}
786
787/**
788 * The events dir the prompt hook's own resolver names for this session's cwd (a checkout whose store is elsewhere), asked
789 * once per session and cwd. The entry is found in the capture hook's order: `$BS_PROMPT_HOOK` when it is an absolute path
790 * to an existing regular file (run with node), else `bs-prompt-hook` on PATH. A missing entry, no output, a non-zero exit or bad
791 * JSON is no root, and no error.
792 */
793async function resolvedRoot($: EngineInterface, st: State, sid: string): Promise<string | null> {
794  const cwd = await $.session.cwd()
795  const key = `${sid}|${cwd}`
796  if (st.resolved.has(key)) return st.resolved.get(key) ?? null
797  let dir: string | null = null
798  try {
799    const entry = await $.env.get('BS_PROMPT_HOOK')
800    const named = entry !== undefined && entry.startsWith('/') && (await $.fs.stat(entry).catch(() => null))?.kind === 'file'
801    const res = await $.process.run(named ? ['node', entry, '--resolve', cwd] : ['bs-prompt-hook', '--resolve', cwd])
802    const out = res.exitCode === 0 ? (JSON.parse(res.stdout.trim().split('\n')[0] ?? '') as { events_dir?: unknown } | null) : null
803    if (typeof out?.events_dir === 'string' && out.events_dir.startsWith('/')) dir = out.events_dir.replace(/\/$/, '')
804  } catch {
805    // no bin, or an answer that is no JSON: no root from it
806  }
807  st.resolved.set(key, dir)
808  return dir
809}
810
811/** The session's own event dirs, the one the resolver named, then the ones Bash commands taught the mod (shared by every session). */
812async function rootsOf($: EngineInterface, own: string[], resolved: string | null): Promise<string[]> {
813  const out = new Set(own)
814  if (resolved) out.add(resolved)
815  for (const root of learned(await $.store.get('roots'))) out.add(root)
816  return [...out]
817}
818
819async function listLogs($: EngineInterface, roots: string[]): Promise<Entry[]> {
820  const out: Entry[] = []
821  for (const dir of roots) {
822    let entries
823    try {
824      entries = await $.fs.list(dir)
825    } catch {
826      continue
827    }
828    for (const en of entries) {
829      if (en.kind === 'file' && en.name.endsWith('.jsonl')) out.push({ path: `${dir}/${en.name}`, name: en.name, size: en.size, mtimeMs: en.mtimeMs })
830    }
831  }
832  return out
833}
834
835/** A log file's epic: the one its content names, else the one its name gives; null for a prompt home log. */
836function epicOf(st: State, en: { path: string; name: string }): string | null {
837  return st.fileEpic.get(en.path) ?? epicOfFile(en.name)
838}
839
840/**
841 * Greps the logs whose epic is unknown for the first one their content names:
842 * a name alone cannot tell `web-audit-1-close-<date>` from an epic of its own.
843 */
844async function resolveEpics($: EngineInterface, st: State, entries: Entry[]): Promise<void> {
845  const fresh = entries.filter(en => !en.name.startsWith('prompts-') && !st.fileEpic.has(en.path) && st.probed.get(en.path) !== en.size)
846  for (let i = 0; i < fresh.length; i += GREP_CHUNK) {
847    const chunk = fresh.slice(i, i + GREP_CHUNK)
848    const res = await $.process.run(['grep', '-m1', '-oE', '-H', '--', EPIC_ERE, ...chunk.map(c => c.path)])
849    // a hit is a fact even when another file of the chunk failed (exit 2)
850    for (const [path, epic] of epicsFromGrep(res.stdout)) st.fileEpic.set(path, epic)
851    if (res.exitCode === 0 || res.exitCode === 1) for (const c of chunk) if (!st.fileEpic.has(c.path)) st.probed.set(c.path, c.size)
852  }
853}
854
855/** Greps the logs not yet ours for this session's id; a hit makes its epic ours. */
856async function discover($: EngineInterface, st: State, sid: string, entries: Entry[]): Promise<void> {
857  const fresh = entries.filter(en => !en.name.startsWith('prompts-') && !st.mine.has(epicOf(st, en) ?? '') && st.scanned.get(en.path) !== en.size)
858  for (let i = 0; i < fresh.length; i += GREP_CHUNK) {
859    const chunk = fresh.slice(i, i + GREP_CHUNK)
860    const res = await $.process.run(['grep', '-l', '-F', '--', sid, ...chunk.map(c => c.path)])
861    if (res.exitCode === 0) {
862      for (const path of res.stdout.split('\n')) {
863        const epic = path ? epicOf(st, { path, name: basename(path) }) : null
864        if (epic) st.mine.add(epic)
865      }
866    }
867    if (res.exitCode === 0 || res.exitCode === 1) for (const c of chunk) st.scanned.set(c.path, c.size)
868  }
869}
870
871function dirOf(path: string): string {
872  return path.slice(0, path.lastIndexOf('/'))
873}
874
875/**
876 * Glue for fold.ts tierOf's plan fallback, kept minimal: the shown epic's latest
877 * plan `effort`, from where Blacksmith latestPlan lists by default (planDirOf of
878 * each dir holding the epic's logs), through `$.fs` only. Read once per plan
879 * version the fold saw and kept in `planTiers`; skipped once an admission names a
880 * tier. A plan written with `--specs-dir` sits elsewhere and is not found.
881 */
882async function readPlanTier($: EngineInterface, st: State, epic: EpicView, entries: Entry[]): Promise<void> {
883  if (epic.admittedTier) return
884  const version = planVersionOf(epic)
885  if ((await read($, planTiersAtom))?.[epic.epicId]?.version === version) return
886  let tier: PlanTier['tier'] = null
887  for (const dir of new Set(entries.filter(en => epicOf(st, en) === epic.epicId).map(en => dirOf(en.path)))) {
888    const plans = planDirOf(dir, epic.epicId)
889    if (!plans) continue
890    try {
891      const name = latestPlanName((await $.fs.list(plans)).filter(f => f.kind === 'file').map(f => f.name))
892      if (name) tier = planEffort(await $.fs.read(`${plans}/${name}`))
893    } catch {
894      // no plan dir there, or an unreadable plan: no tier from it
895    }
896    if (tier) break
897  }
898  await update($, planTiersAtom, held => ({ ...held, [epic.epicId]: { version, tier } }))
899}
900
901/** The complete lines each tracked file gained since its cursor. */
902async function readChanged($: EngineInterface, st: State, tracked: Entry[]): Promise<Line[]> {
903  const out: Line[] = []
904  for (const en of tracked) {
905    const cur = st.cursors.get(en.path)
906    if (cur && cur.size === en.size) continue
907    const from = cur?.lines ?? 0
908    const res = await $.process.run(['tail', '-n', `+${from + 1}`, en.path])
909    if (res.exitCode !== 0) continue
910    const { lines, count } = splitLines(res.stdout)
911    const base = en.name.replace(/\.jsonl$/, '')
912    lines.forEach((text, i) => {
913      let ev: BsEvent
914      try {
915        ev = JSON.parse(text) as BsEvent
916      } catch {
917        return
918      }
919      if (!ev || typeof ev.event_type !== 'string') return
920      out.push({ ev, ref: `${base}#${from + i}`, ts: (ev.ts && Date.parse(ev.ts)) || 0, isNewFile: from === 0, fileEpic: epicOf(st, en) })
921    })
922    // A partial last line or a capped read means the file holds more than was folded.
923    const isWhole = !res.isStdoutTruncated && (res.stdout === '' || res.stdout.endsWith('\n'))
924    st.cursors.set(en.path, { lines: from + count, size: isWhole ? en.size : -1 })
925  }
926  return out
927}
928
929async function tick($: EngineInterface, st: State, isRetry = false): Promise<void> {
930  const sid = await $.session.id()
931  const now = await $.clock.now()
932  const pinned = await read($, pinnedAtom)
933  const own = await ownRoots($)
934  const roots = await rootsOf($, own, sid ? await resolvedRoot($, st, sid) : null)
935  const entries = await listLogs($, roots)
936  st.lastRoots = roots
937  await resolveEpics($, st, entries)
938  st.lastEpics = entries.flatMap(en => epicOf(st, en) ?? [])
939  if (sid) await discover($, st, sid, entries)
940
941  const followed = new Set([...st.mine, ...(pinned ? [pinned] : [])])
942  /** the epics' files, and the home logs: this session's own, and every one an epic's event named by parent_prompt_id */
943  const trackedOf = () => {
944    const homes = new Set([`prompts-${sid}`, ...(st.hud.homes ?? [])].map(h => `${h}.jsonl`))
945    return entries.filter(en => (homes.has(en.name) ? Boolean(sid) : followed.has(epicOf(st, en) ?? '')))
946  }
947  const tracked = trackedOf()
948  // The first read is the history the session booted on: fold it, toast none of it.
949  let isQuiet = !st.isBooted
950  if (tracked.some(en => (st.cursors.get(en.path)?.size ?? -1) > en.size)) {
951    resetFold(st)
952    isQuiet = true
953  }
954  let batch = await readChanged($, st, tracked)
955  // A file newly followed may hold events older than what is folded, and the
956  // fold is order-sensitive: fold everything again, in time order.
957  if (st.maxTs > 0 && batch.some(r => r.isNewFile && r.ts < st.maxTs)) {
958    resetFold(st)
959    isQuiet = true
960    batch = await readChanged($, st, tracked)
961  }
962  batch.sort((a, b) => a.ts - b.ts)
963
964  for (const r of batch) {
965    const notices = foldEvent(st.hud, r.ev, r.ref, sid, r.fileEpic)
966    if (r.ts > st.maxTs) st.maxTs = r.ts
967    if (isQuiet || now - r.ts > FRESH_MS) continue
968    for (const n of notices) $.ui.toast(n.text, { timeoutMs: TOAST_MS })
969  }
970  if (batch.length > 0 || isQuiet) {
971    const copy = JSON.parse(JSON.stringify(st.hud)) as Hud
972    await update($, hudAtom, () => copy)
973  }
974
975  const minute = Math.floor(now / 60_000)
976  if (minute !== st.lastMinute) {
977    st.lastMinute = minute
978    await update($, minuteAtom, () => minute)
979  }
980
981  const view = pickView(st.hud, pinned)
982  if (view) await readPlanTier($, st, view.epic, entries)
983  const status = view ? statusOf(summarize(view.epic, now)) : undefined
984  if (status !== st.lastStatus) {
985    st.lastStatus = status
986    $.ui.status(status)
987  }
988  st.isBooted = true
989  // a home log an epic's event just named is read now, not a tick later; one that was tracked and failed to read is not retried
990  const before = new Set(tracked.map(en => en.path))
991  if (!isRetry && trackedOf().some(en => !before.has(en.path) && !st.cursors.has(en.path))) await tick($, st, true)
992}
993
994/** Starts a tick unless one runs; resolves when the running one ends. */
995function kick($: EngineInterface, st: State): Promise<void> {
996  if (!st.running) {
997    st.running = tick($, st)
998      .then(() => {
999        st.lastError = ''
1000      })
1001      .catch((err: unknown) => {
1002        st.lastError = messageOf(err)
1003      })
1004      .finally(() => {
1005        st.running = null
1006      })
1007  }
1008  return st.running
1009}
1010
1011/** A tick that starts after the call: one already running may predate a pin. */
1012async function refresh($: EngineInterface, st: State): Promise<void> {
1013  if (st.running) await st.running
1014  await kick($, st)
1015}
1016
1017/** What the band, pane and command draw, as pickView decides it from `$.state`. */
1018async function viewOf($: EngineInterface): Promise<ReturnType<typeof pickView>> {
1019  return pickView(await read($, hudAtom), await read($, pinnedAtom))
1020}
1021
1022async function openPane($: EngineInterface, epicId: string): Promise<void> {
1023  await $.ui.open({ id: PANE, title: `bs · ${epicId}` })
1024}
1025
1026export const register: Register = on => {
1027  const st = newState()
1028
1029  on('session.start', async ($, e, next) => {
1030    await $.command.register({
1031      name: 'bs-mod',
1032      description: 'Blacksmith HUD: open the pane; <epic-id> pins an epic, auto follows this session, off/on hides the band',
1033      argumentHint: '[epic-id|auto|off|on]',
1034    })
1035    st.timer?.cancel()
1036    st.timer = $.clock.every(TICK_MS, () => void kick($, st))
1037    void kick($, st)
1038    const started = await next(e)
1039    try {
1040      const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
1041      await update($, themeAtom, () => (typeof theme === 'string' ? theme : null))
1042    } catch {
1043      // no theme read: the theme keys draw until a theme is set
1044    }
1045    return started
1046  })
1047
1048  // A theme written from /config or a plugin repaints the band and the pane; a deny or a failed write keeps the palette.
1049  on('config.set', { key: 'theme' }, async ($, e, next) => {
1050    const set = await next(e)
1051    if (set.deny === undefined && typeof set.value === 'string') {
1052      const theme = set.value
1053      try {
1054        await update($, themeAtom, () => theme)
1055      } catch {
1056        // the palette stays as it was
1057      }
1058    }
1059    return set
1060  }).catch(($, e, next) => next(e))
1061
1062  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1063    const ran = await next(e)
1064    // a shell the main loop sent to the background: the idle band's Tasks row counts it until it ends
1065    const shell = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
1066    if (!e.agentId && typeof shell === 'string' && shell) await update($, bgAtom, bg => bgStart(bg, shell, 'shell'))
1067    if (!BS_COMMAND.test(e.command)) return ran
1068    try {
1069      const found = parseBsRoots(e.command, await $.session.cwd(), (await $.env.get('HOME')) ?? '')
1070      if (found.length > 0) {
1071        const known = learned(await $.store.get('roots'))
1072        const merged = [...new Set([...found, ...known])].slice(0, ROOTS_CAP)
1073        if (merged.join('\n') !== known.join('\n')) await $.store.set('roots', merged)
1074      }
1075    } catch (err) {
1076      st.lastError = messageOf(err)
1077    }
1078    void kick($, st)
1079    return ran
1080  }).catch(($, e, next) => next(e))
1081
1082  // The main loop's task list, for the idle band; a subagent's tools (agentId set) are its own list.
1083  for (const tool of ['TaskCreate', 'TaskUpdate', 'TaskList', 'TodoWrite'] as const) {
1084    on('tool.call', { tool: tool as 'TaskCreate' }, async ($, e, next) => {
1085      const ran = await next(e)
1086      if (!e.agentId) await update($, taskListAtom, list => foldTaskTool(list, tool, e, ran.result))
1087      return ran
1088    }).catch(($, e, next) => next(e))
1089  }
1090
1091  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
1092    const ran = await next(e)
1093    const id = (ran.result as { taskId?: unknown } | undefined)?.taskId
1094    if (!e.agentId && typeof id === 'string' && id) await update($, bgAtom, bg => bgStart(bg, id, 'monitor'))
1095    return ran
1096  }).catch(($, e, next) => next(e))
1097
1098  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
1099    const ran = await next(e)
1100    const id = e.task_id ?? e.shell_id
1101    if (!e.agentId && id) await update($, bgAtom, bg => bgEnd(bg, id))
1102    return ran
1103  }).catch(($, e, next) => next(e))
1104
1105  // A background task's notification arrives as a prompt: any status but running ends the work it names.
1106  on('prompt.submit', async ($, e, next) => {
1107    const id = e.origin.kind === 'task-notification' ? notificationEnd(e.text) : null
1108    if (id) await update($, bgAtom, bg => bgEnd(bg, id))
1109    return next(e)
1110  }).catch(($, e, next) => next(e))
1111
1112  on('command.run', { command: 'bs-mod' }, async ($, e) => {
1113    const arg = e.args.trim()
1114    if (arg === 'off' || arg === 'on') {
1115      await update($, hiddenAtom, () => arg === 'off')
1116      return { text: arg === 'off' ? 'bs-mod band hidden; /bs-mod on brings it back.' : 'bs-mod band shown.' }
1117    }
1118    if (arg === 'auto') {
1119      await update($, pinnedAtom, () => null)
1120      await refresh($, st)
1121      return { text: 'bs-mod follows the epic this session drives.' }
1122    }
1123    if (arg) {
1124      if (!EPIC_ID.test(arg)) return { text: `bs-mod: not an epic id: ${arg}` }
1125      const before = await read($, pinnedAtom)
1126      await update($, pinnedAtom, () => arg)
1127      await refresh($, st)
1128      if (!st.lastEpics.includes(arg)) {
1129        await update($, pinnedAtom, () => before)
1130        await refresh($, st)
1131        return { text: `bs-mod: no event log found for ${arg} under ${st.lastRoots.join(', ')}` }
1132      }
1133      await update($, hiddenAtom, () => false)
1134      await openPane($, arg)
1135      return { text: `Pinned ${arg}; /bs-mod auto follows this session again.` }
1136    }
1137    // a tick that starts now: one already running may predate the session's last event
1138    await refresh($, st)
1139    const view = await viewOf($)
1140    if (!view) return { text: 'No bs epic in this session yet; /bs-mod <epic-id> pins one.' }
1141    await openPane($, view.epic.epicId)
1142    return { text: `bs-mod pane opened on ${view.epic.epicId}.` }
1143  })
1144
1145  // The band always draws, the idle band with no epic in view; it passes only to a survey and when hidden.
1146  // AbovePrompt is one instance, so the band stacks over what the plugins beneath draw through next.
1147  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1148    if (e.props.hasSurvey || (await read($, hiddenAtom))) return next(e)
1149    const view = await viewOf($)
1150    await read($, minuteAtom)
1151    const below = await next(e)
1152    const stacked = drawable(below)
1153    const { Box, Button, Text } = $.ui.resolve(e)
1154    const width = e.props.bodyColumns
1155    const pal = paletteOf(await read($, themeAtom))
1156    const texts = (runs: Run[]) => runs.map(r => <Text {...style(r, pal)}>{r.text}</Text>)
1157    const agentList = async () => {
1158      try {
1159        return await $.agent.list()
1160      } catch {
1161        // no agent list: the session's count reads 0 until it answers
1162        return []
1163      }
1164    }
1165    const liveAgents = async () => activeSessionAgents(await agentList()).length
1166    // maxRows is read-only: a hook cannot hand the plugins beneath fewer rows, and a column taller than maxRows
1167    // scrolls with the band at its top. So the band takes its rows first, one less when stacked: a one-row tree
1168    // beneath fits, a taller one scrolls under the band, which stays whole.
1169    const fit = bandFit(Math.max(1, e.props.maxRows - (stacked ? 1 : 0)))
1170    const band = (accent: string, tabRow: RenderElement, body: BandRow[]) => {
1171      const rows: RenderElement[] = []
1172      // an empty Text, not marginTop: a row the harness finds and counts, the same row in Ink
1173      if (fit.blank) rows.push(<Box key="blank"><Text>{' '}</Text></Box>)
1174      if (fit.rule) rows.push(<Box key="rule">{texts([rule(width, accent)])}</Box>)
1175      rows.push(tabRow)
1176      for (const r of body.slice(0, fit.body)) rows.push(<Box key={r.key} flexDirection="row">{texts(clip(r.runs, width))}</Box>)
1177      if (stacked) rows.push(<Box key="below" flexDirection="column">{below}</Box>)
1178      return <Box flexDirection="column">{rows}</Box>
1179    }
1180
1181    if (!view) {
1182      const tabRow = <Box key="tabs" flexDirection="row">{texts([badge(OVERVIEW_TAB)])}</Box>
1183      if (fit.body <= 0) return band(OVERVIEW_TAB.accent, tabRow, [])
1184      const agents = await agentList()
1185      const progress = progressOf((await read($, taskListAtom)) ?? [], (await read($, bgAtom)) ?? { started: {}, ended: [] }, agents)
1186      const homePrompts = (await read($, hudAtom))?.homePrompts ?? []
1187      return band(OVERVIEW_TAB.accent, tabRow, idleRows(activeSessionAgents(agents).length, progress, homePrompts, await $.clock.now(), width))
1188    }
1189    const epic = view.epic
1190    const now = await $.clock.now()
1191    const s = summarize(epic, now)
1192    const picked = await read($, tabAtom)
1193    const active = TABS.find(t => t.id === picked) ?? OVERVIEW_TAB
1194
1195    // the tab row: the tabs on the left; the waits, the S1/S2 count and Details on the right, dropped in reverse
1196    // order when the row is narrow, before the tabs give anything up
1197    const waiting = s.waits.waivers + s.waits.escalations + s.waits.specs
1198    const side: { w: number; el: RenderElement }[] = []
1199    for (const r of [
1200      waiting ? run(`⚑ ${waiting} waiting on you`, { color: 'warning', bold: true }) : null,
hooks/fold.ts 1480 lines
1// The pure half of bs-mod: folds Blacksmith event-log lines into the views the
2// band and pane draw. Task status mirrors factory/orchestrator/src/db/projector.ts
3// (`foldTasks`); agents, waits and activity are the HUD's own reading.
4import type { AdmissionView, AgentView, BgState, EpicView, FindingView, Hud, PromptView, TaskItem, TaskView, Tone, WaveView } from '../types'
5import type { AgentInfo, AgentStatus } from 'claude-code'
6
7export type { BgState, TaskItem }
8
9export type BsEvent = {
10  session_id?: string
11  actor?: string
12  event_type: string
13  task_id?: string | null
14  payload?: Record<string, unknown>
15  cli_session_id?: string | null
16  ts?: string
17  /** `"<session_id>#<line index>"` of the event that caused this one; see walkToPrompt */
18  causal_parent?: string | null
19}
20
21export type Notice = { text: string; tone: Tone }
22
23export type Summary = {
24  epicId: string
25  wave: number
26  now: { role: string; task: string; elapsed: string } | null
27  agentCount: number
28  /** the live agents' roles, newest first */
29  roles: string[]
30  staleCount: number
31  /** the live agents, newest first: open dispatches and one wave-runner per live wave session */
32  agents: AgentView[]
33  next: string | null
34  isWaitingOnYou: boolean
35  counts: { done: number; review: number; active: number; todo: number; superseded: number; total: number }
36  progress: number
37  budget: { cap: number; projected: number; pct: number } | null
38  /** decisions only the operator can take */
39  waits: { waivers: number; escalations: number; specs: number }
40  /** open S1/S2 findings: they block the epic's close, but the factory works them */
41  blockers: { total: number; byStatus: Record<string, number> }
42  /** amend-pending findings whose amended tasks all landed (Blacksmith D-127 Part B): neither open nor blocking */
43  amendsLanded: number
44  findings: Record<'S1' | 'S2' | 'S3' | 'S4', number>
45  isClosed: boolean
46}
47
48// mirror Blacksmith taskStatus.ts TERMINAL_TASK_STATUSES and TERMINAL_OK_TASK_STATUSES
49const TERMINAL = new Set(['completed', 'superseded', 'failed', 'escalated', 'waived'])
50const LANDED = new Set(['completed', 'waived'])
51const CLOSED_FINDING = new Set(['fix-verified', 'waived', 'refuted', 'expired', 'amended'])
52// mirrors Blacksmith projector.ts NOTE_ONLY_SEVERITIES: an error at these leaves its task where it is
53const NOTE_ONLY = new Set(['S3-minor', 'S4-nit'])
54const ACTIVITY_CAP = 30
55const STALE_MS = 3 * 3600_000
56// a working wave session is rarely silent over an hour; a longer silence is a pause, a takeover or an abandoned wave
57export const WAVE_IDLE_MS = 60 * 60_000
58const WAVE_RUNNER = 'wave-runner'
59
60export function emptyHud(): Hud {
61  return { epics: {} }
62}
63
64function str(v: unknown): string | null {
65  return typeof v === 'string' && v.length > 0 ? v : null
66}
67
68// a wave part is `w4`, `w1r` or `wave-3`, maybe with a word before the date (`w2-takeover-<date>`)
69function stripSession(sid: string): string {
70  return sid.replace(/-(?:w|wave-)\d+[a-z]?(?:-[a-z]+)?-\d{4}-\d{2}-\d{2}.*$/, '').replace(/-\d{4}-\d{2}-\d{2}.*$/, '')
71}
72
73/** A home log's session id prefix (`prompts-<cli id>`): it holds one CLI session's prompts, never an epic's events. */
74const HOME_LOG = 'prompts-'
75
76/** An event's epic: its payload `epic_id`, its task id's `<epic>/` prefix, the epic its log file names, then its session id; a home log has none. */
77export function epicIdOf(ev: BsEvent, fileEpic?: string | null): string | null {
78  const fromPayload = str(ev.payload?.epic_id)
79  if (fromPayload) return fromPayload
80  const task = str(ev.task_id)
81  if (task && task.includes('/')) return task.slice(0, task.indexOf('/'))
82  // a session-start names no epic, and a session id may end in a word (`-close-<date>`) no name rule can strip
83  if (fileEpic) return fileEpic
84  const sid = str(ev.session_id)
85  if (sid?.startsWith(HOME_LOG)) return null
86  return sid ? stripSession(sid) : null
87}
88
89/** A wave session's label: `w4`, `w1r`, `w3` for `wave-3`, `w6` for `-w6-<date>-takeover`; null for the epic's own session. */
90export function waveLabel(sessionId: string, epicId = stripSession(sessionId)): string | null {
91  const prefix = `${epicId}-`
92  if (!sessionId.startsWith(prefix)) return null
93  const m = /^(?:w|wave-)(\d+[a-z]?)(?=-|$)/.exec(sessionId.slice(prefix.length))
94  return m ? `w${m[1]}` : null
95}
96
97export function waveOf(sessionId: string, epicId?: string): number {
98  const label = waveLabel(sessionId, epicId)
99  return label ? parseInt(label.slice(1), 10) : 0
100}
101
102function escapeRe(s: string): string {
103  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
104}
105
106export function parseBsHome(command: string, cwd: string, home: string): string | null {
107  const re = /(?:^|[\s;&|(])(?:export\s+)?(?:BS_HOME|SMITH_HOME)=("[^"]*"|'[^']*'|[^\s;&|)]+)/g
108  let last: string | null = null
109  for (const m of command.matchAll(re)) last = m[1] ?? null
110  if (last === null) return null
111  let value = last
112  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1)
113  if (!value || value.includes('$') || value.includes('`')) return null
114  if (value === '~' || value.startsWith('~/')) value = home + value.slice(1)
115  if (!value.startsWith('/')) value = `${cwd.replace(/\/$/, '')}/${value.replace(/^\.\//, '')}`
116  return value.replace(/\/$/, '')
117}
118
119/**
120 * `grep -E` for a log's epic: a payload `epic_id`, or a task id with an `<epic>/`
121 * prefix. A bare task id or an audit run id has no slash and never matches.
122 */
123export const EPIC_ERE = '"epic_id":"[^"/]+"|"task_id":"[^"/]+/'
124
125/** Reads `grep -m1 -oE -H -- EPIC_ERE <paths>`: each path's epic, its first hit winning. */
126export function epicsFromGrep(stdout: string): Map<string, string> {
127  const out = new Map<string, string>()
128  for (const ln of stdout.split('\n')) {
129    // anchored at the end, so a colon inside the path stays in the path
130    const m = /^(.+):"(?:epic_id":"([^"/]+)"|task_id":"([^"/]+)\/)$/.exec(ln)
131    const path = m?.[1]
132    const epic = m?.[2] ?? m?.[3]
133    if (path && epic && !out.has(path)) out.set(path, epic)
134  }
135  return out
136}
137
138/** The epic a log file is named for: its session id less the wave and date; null for a home log. */
139export function epicOfFile(name: string): string | null {
140  return name.startsWith(HOME_LOG) ? null : stripSession(name.replace(/\.jsonl$/, ''))
141}
142
143const CLONE_CLI = /(?:^|[\s;&|(=])([^\s;&|()'"`]*)\/factory\/orchestrator\/dist\/cli\.js\b/g
144
145/**
146 * The event directories a Bash command writes to: its declared home's, else
147 * each Blacksmith clone whose CLI it runs (a clone writes into itself).
148 */
149export function parseBsRoots(command: string, cwd: string, home: string): string[] {
150  const declared = parseBsHome(command, cwd, home)
151  if (declared) return [`${declared}/state/events`]
152  const roots = new Set<string>()
153  for (const m of command.matchAll(CLONE_CLI)) {
154    let root = m[1]
155    if (!root || root.includes('$')) continue
156    if (root === '~' || root.startsWith('~/')) root = home + root.slice(1)
157    if (!root.startsWith('/')) root = `${cwd.replace(/\/$/, '')}/${root.replace(/^\.\//, '')}`
158    roots.add(`${root.replace(/\/$/, '')}/state/events`)
159  }
160  return [...roots]
161}
162
163export function splitLines(text: string): { lines: string[]; count: number } {
164  const end = text.lastIndexOf('\n')
165  if (end < 0) return { lines: [], count: 0 }
166  const lines = text.slice(0, end).split('\n')
167  return { lines, count: lines.length }
168}
169
170export function shortTask(id: string): string {
171  const m = /(task-\d+[a-z]?)(?:-|$)/.exec(id)
172  if (m?.[1]) return m[1]
173  return id.slice(id.lastIndexOf('/') + 1)
174}
175
176export function fmtTok(n: number): string {
177  if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`
178  if (n >= 1_000) return `${trim(n / 1_000)}k`
179  return String(Math.round(n))
180}
181
182function trim(x: number): string {
183  return x >= 100 ? String(Math.round(x)) : String(Math.round(x * 10) / 10)
184}
185
186export function fmtElapsed(ms: number): string {
187  const s = Math.max(0, Math.floor(ms / 1000))
188  if (s < 60) return `${s}s`
189  const m = Math.floor(s / 60)
190  if (m < 60) return `${m}m`
191  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
192}
193
194/**
195 * The colors the band and the pane draw by meaning, each role named by the theme key it stood for; `active` was a
196 * teal constant, no theme key being teal; `label` is the soft white of the labels and the Overview (subtext0). The neutrals (text, inverseText, inactive, subtle, dim) stay theme keys.
197 */
198export type PaletteRole = 'active' | 'success' | 'error' | 'warning' | 'permission' | 'planMode' | 'remember' | 'claude' | 'ide' | 'merged' | 'autoAccept' | 'suggestion' | 'label'
199export type Palette = Readonly<Record<PaletteRole, string>>
200
201/** Catppuccin Mocha, for a dark theme. */
202export const MOCHA: Palette = {
203  active: '#94e2d5', success: '#a6e3a1', error: '#f38ba8', warning: '#f9e2af', permission: '#89b4fa', planMode: '#cba6f7',
204  remember: '#b4befe', claude: '#fab387', ide: '#89dceb', merged: '#f5c2e7', autoAccept: '#f2cdcd', suggestion: '#74c7ec', label: '#a6adc8',
205}
206
207/** Catppuccin Latte, for a light theme. */
208export const LATTE: Palette = {
209  active: '#179299', success: '#40a02b', error: '#d20f39', warning: '#df8e1d', permission: '#1e66f5', planMode: '#8839ef',
210  remember: '#7287fd', claude: '#fe640b', ide: '#04a5e5', merged: '#ea76cb', autoAccept: '#dd7878', suggestion: '#209fb5', label: '#6c6f85',
211}
212
213/** The engine's own theme keys, and the teal: what a daltonized, `auto` or unknown theme keeps. */
214export const THEME_KEYS: Palette = {
215  active: '#14b8a6', success: 'success', error: 'error', warning: 'warning', permission: 'permission', planMode: 'planMode',
216  remember: 'remember', claude: 'claude', ide: 'ide', merged: 'merged', autoAccept: 'autoAccept', suggestion: 'suggestion', label: 'inactive',
217}
218
219/**
220 * The palette for a `/config` theme name: Latte for `light*`, Mocha for `dark*` (the `-ansi` themes included), the
221 * theme keys for anything else. A daltonized theme is colour-blind safe, so it keeps the engine's own colors.
222 */
223export function paletteOf(theme: string | null | undefined): Palette {
224  if (typeof theme !== 'string' || theme.includes('daltonized')) return THEME_KEYS
225  if (theme.startsWith('light')) return LATTE
226  if (theme.startsWith('dark')) return MOCHA
227  return THEME_KEYS
228}
229
230export function bar(frac: number, width: number): string {
231  const f = Math.min(1, Math.max(0, frac))
232  const full = Math.round(f * width)
233  return '█'.repeat(full) + '░'.repeat(width - full)
234}
235
236export type Cells = { done: number; review: number; active: number; todo: number }
237
238/** The cells of a `width`-cell bar each status takes, in bar order; they sum to `width`. */
239export function segments(c: Cells, width: number): Cells {
240  const order = [c.done, c.review, c.active, c.todo]
241  const total = order.reduce((n, x) => n + x, 0)
242  if (total === 0) return { done: 0, review: 0, active: 0, todo: width }
243  let cum = 0
244  let edge = 0
245  const [done = 0, review = 0, active = 0, todo = 0] = order.map(n => {
246    cum += n
247    const next = Math.round((cum * width) / total)
248    const cells = next - edge
249    edge = next
250    return cells
251  })
252  return { done, review, active, todo }
253}
254
255function newEpic(epicId: string): EpicView {
256  return {
257    epicId,
258    admitted: 0,
259    waveMax: 0,
260    waveTaskIds: [],
261    tasks: {},
262    agents: {},
263    waves: {},
264    taskWave: {},
265    budget: null,
266    pendingWaivers: [],
267    escalations: {},
268    openFindings: {},
269    successors: {},
270    pendingSpecChanges: [],
271    activity: [],
272    lastTs: 0,
273    isClosed: false,
274    isMine: false,
275  }
276}
277
278/** The epic's own id, its integration branch or a plan ref: mirrors Blacksmith projector.ts touch()'s reserved refs. */
279function isReserved(epicId: string, id: string): boolean {
280  // `norm` prefixes a bare epic id, so the epic's own id arrives as `<epic>/<epic>`
281  return id === epicId || id === `${epicId}/${epicId}` || id === `${epicId}/integration` || new RegExp(`^${escapeRe(epicId)}/plan-v\\d+$`).test(id)
282}
283
284function norm(epicId: string, id: string): string {
285  return id.includes('/') ? id : `${epicId}/${id}`
286}
287
288/**
289 * Mints the row of a task an admission, gate, merge or supersede names before any `task-added` did:
290 * mirrors Blacksmith projector.ts touch(), never for a reserved ref. Origin and plan version stay null
291 * until a `task-added` fills them in; the title falls back to the id, as task-added's does.
292 */
293function ensureRow(epic: EpicView, epicId: string, id: string): void {
294  if (!epic.tasks[id] && !isReserved(epicId, id)) epic.tasks[id] = { status: 'todo', title: id, origin: null, planVersion: null }
295}
296
297/** Moves a task unless it is terminal; `force` overwrites a terminal status too. */
298function setStatus(epic: EpicView, id: string, status: string, force = false): void {
299  const t = epic.tasks[id]
300  if (t && (force || !TERMINAL.has(t.status))) t.status = status
301}
302
303/** Times the task's newest merge or supersede; `??=`: a hud persisted before these were timed has no map. */
304function doneAt(epic: EpicView, id: string, ts: number): void {
305  const at = (epic.doneAt ??= {})
306  if (ts > (at[id] ?? 0)) at[id] = ts
307}
308
309function closeAgents(epic: EpicView, task: string, keep?: (a: AgentView) => boolean): void {
310  for (const [k, a] of Object.entries(epic.agents)) if (a.taskId === task && !(keep && keep(a))) delete epic.agents[k]
311}
312
313function dropWaits(epic: EpicView, task: string): void {
314  epic.pendingWaivers = epic.pendingWaivers.filter(t => t !== task)
315  for (const [k, t] of Object.entries(epic.escalations)) if (t === task) delete epic.escalations[k]
316}
317
318/** What a quorum case is about; mirrors Blacksmith quorumEscalations.ts `subjectOf`. */
319function subjectOf(p: Record<string, unknown>): 'finding' | 'plan' | 'epic' {
320  if ('blocks' in p) return 'finding'
321  if ('sound' in p) return 'plan'
322  if ('ready' in p) return 'epic'
323  if (str(p.fingerprint)) return 'finding'
324  if (typeof p.plan_version === 'number') return 'plan'
325  return 'epic'
326}
327
328function sevCode(sev: unknown): string {
329  return typeof sev === 'string' ? sev.slice(0, 2) : ''
330}
331
332function taskRefs(epic: EpicView, ev: BsEvent): string[] {
333  const own = str(ev.task_id)
334  const raw = own ? [own] : (str(ev.payload?.task_ref) ?? '').split(',')
335  return raw.map(s => s.trim()).filter(Boolean).map(s => norm(epic.epicId, s))
336}
337
338// ── Prompts: which of the operator's prompts asked for which work ──────────────────────────────
339//
340// Every event's envelope carries `causal_parent`, `"<session_id>#<n>"`: n is the parent's 0-based line
341// index in `<session_id>.jsonl` (log line number − 1), the very ref the fold is handed for each line
342// (register.tsx `${base}#${from + i}`, base = the file name less `.jsonl`). Checked on all 11,505 real
343// events (k-work/parents.ts): no ref past its file's end, none pointing forward or later in ts, 19 with no
344// parent; 100 sit in another session's file, 13 of those in another epic's.
345
346/** The most parent edges a walk follows before it gives up. */
347export const PROMPT_HOPS = 50
348
349/** What the walk needs of one event: its type, its `causal_parent` (null when it has none) and the prompt its payload's `parent_prompt_id` cites. */
350export type Link = { type: string; parent: string | null; cite?: string }
351
352/**
353 * A walk's result: the nearest `user_prompt` ancestor's ref, or null; `hops` = parent edges followed to a known
354 * event (1 when the start cites the prompt itself); `end` says why it stopped.
355 */
356export type Walk = { prompt: string | null; hops: number; end: 'prompt' | 'missing' | 'root' | 'cycle' | 'cap' }
357
358/** Walks `causal_parent` up from the event `start` to its nearest `user_prompt` ancestor: at most `cap` hops, stopping on a cycle or a parent `linkOf` does not know. A node whose `parent_prompt_id` cites a known `user_prompt` ends the walk there, as a final hop. */
359export function walkToPrompt(start: string, linkOf: (ref: string) => Link | undefined, cap = PROMPT_HOPS): Walk {
360  let cur = linkOf(start)
361  if (!cur) return { prompt: null, hops: 0, end: 'missing' }
362  const seen = new Set([start])
363  for (let hops = 0; ; ) {
364    if (cur.cite && hops < cap && linkOf(cur.cite)?.type === 'user_prompt') return { prompt: cur.cite, hops: hops + 1, end: 'prompt' }
365    const parent = cur.parent
366    if (!parent) return { prompt: null, hops, end: 'root' }
367    if (seen.has(parent)) return { prompt: null, hops, end: 'cycle' }
368    if (hops >= cap) return { prompt: null, hops, end: 'cap' }
369    const next = linkOf(parent)
370    if (!next) return { prompt: null, hops, end: 'missing' }
371    hops += 1
372    if (next.type === 'user_prompt') return { prompt: parent, hops, end: 'prompt' }
373    seen.add(parent)
374    cur = next
375  }
376}
377
378/** Each prompt ref once, in first-seen order; the nulls of walks that found none dropped. */
379export function distinctPrompts(refs: Iterable<string | null | undefined>): string[] {
380  const out: string[] = []
381  for (const r of refs) if (r && !out.includes(r)) out.push(r)
382  return out
383}
384
385/** A stored prompt's longest text, an ellipsis past it: one huge prompt reaches tens of KB and the band shows one line. */
386export const PROMPT_CHARS = 240
387
388/** A prompt's text as the epic keeps it: whitespace runs (newlines too) folded to one space, trimmed, cut to PROMPT_CHARS plus `…`. */
389function promptLine(text: string): string {
390  const one = text.replace(/\s+/g, ' ').trim()
391  return one.length > PROMPT_CHARS ? `${one.slice(0, PROMPT_CHARS)}…` : one
392}
393
394/**
395 * What the walk reads, per hud, in memory only: every folded event's link, and each `user_prompt`'s time and cut text.
396 * A WeakMap keeps it out of the hud register persists (the biggest epic's links alone run ~190 KB) and drops it with
397 * the hud on a reset; register rebuilds the hud from the logs at load, so nothing is lost across a reload.
398 */
399const indexes = new WeakMap<Hud, { links: Map<string, Link>; prompts: Map<string, PromptView> }>()
400
401function indexOf(hud: Hud) {
402  let ix = indexes.get(hud)
403  if (!ix) indexes.set(hud, (ix = { links: new Map(), prompts: new Map() }))
404  return ix
405}
406
407/**
408 * The nearest prompt `ref`'s causal chain reaches, copied into the epic's kept prompts; null when none.
409 * A parent folded later than its child is missed: register sorts each batch by ts and re-folds from the start
410 * when it follows a file with older events, and a parent is never later in ts than its child.
411 */
412function reachPrompt(hud: Hud, epic: EpicView, ref: string): string | null {
413  const ix = indexOf(hud)
414  const { prompt } = walkToPrompt(ref, r => ix.links.get(r))
415  const view = prompt ? ix.prompts.get(prompt) : undefined
416  if (!prompt || !view) return null
417  ;(epic.prompts ??= {})[prompt] ??= view
418  return prompt
419}
420
421/** The newest prompts an epic keeps whether or not any work descends from them. */
422export const EPIC_PROMPTS = 20
423/** The newest prompts of the own home log the idle band keeps. */
424const HOME_PROMPTS = 2
425
426/** A `parent_prompt_id` that names a home-log prompt (`prompts-<id>#<n>`), or null. */
427function homeRef(v: unknown): string | null {
428  return typeof v === 'string' && v.startsWith(HOME_LOG) && /#\d+$/.test(v) ? v : null
429}
430
431/**
432 * Keeps an epic-log prompt on the epic and drops the oldest past EPIC_PROMPTS. A prompt some admission, task or kept
433 * wave points at stays: Past and Next read it by ref.
434 */
435function keepPrompt(epic: EpicView, ref: string, view: PromptView | undefined): void {
436  if (!view) return
437  const kept = (epic.prompts ??= {})
438  kept[ref] ??= view
439  const refs = Object.keys(kept)
440  if (refs.length <= EPIC_PROMPTS) return
441  const pinned = new Set([...Object.values(epic.taskPrompts ?? {}).flat(), ...(epic.admissions ?? []).map(a => a.prompt)])
442  const oldest = refs.filter(r => !pinned.has(r)).sort((a, b) => kept[a]!.ts - kept[b]!.ts)
443  for (const r of oldest.slice(0, refs.length - EPIC_PROMPTS)) delete kept[r]
444}
445
446/** Folds one event into `hud` in place; returns what is worth a toast. */
447export function foldEvent(hud: Hud, ev: BsEvent, ref: string, sid: string, fileEpic?: string | null): Notice[] {
448  const p = ev.payload ?? {}
449  const ts = ev.ts ? Date.parse(ev.ts) || 0 : 0
450  // every event's link, an epic's or not: a work event's chain can cross into another session's or epic's log
451  const ix = indexOf(hud)
452  ix.links.set(ref, { type: ev.event_type, parent: str(ev.causal_parent), cite: str(p.parent_prompt_id) ?? undefined })
453  if (ev.event_type === 'user_prompt') ix.prompts.set(ref, { ts, text: promptLine(typeof p.prompt === 'string' ? p.prompt : '') })
454  const epicId = epicIdOf(ev, fileEpic)
455  if (!epicId) {
456    // the own home log's newest prompts are the idle band's; no other home log is kept whole
457    if (ev.event_type === 'user_prompt' && sid && ev.session_id === `${HOME_LOG}${sid}`) {
458      const view = ix.prompts.get(ref)
459      if (view) hud.homePrompts = [{ ref, ...view }, ...(hud.homePrompts ?? [])].sort((a, b) => b.ts - a.ts).slice(0, HOME_PROMPTS)
460    }
461    return []
462  }
463  const epic = (hud.epics[epicId] ??= newEpic(epicId))
464  const notices: Notice[] = []
465  const task = str(ev.task_id) ? norm(epicId, ev.task_id as string) : null
466  const short = task ? shortTask(task) : ''
467  const note = (text: string, tone: Tone, toast = false) => {
468    epic.activity.push({ ts, text, tone })
469    if (epic.activity.length > ACTIVITY_CAP) epic.activity.splice(0, epic.activity.length - ACTIVITY_CAP)
470    if (toast) notices.push({ text, tone })
471  }
472
473  if (ev.event_type === 'user_prompt') keepPrompt(epic, ref, ix.prompts.get(ref))
474  // a prompt typed in another session's home log can open an epic's session or dispatch its work
475  const home = ev.event_type === 'session-start' || ev.event_type === 'dispatch_decision' ? homeRef(p.parent_prompt_id) : null
476  if (home) {
477    const homeSession = home.slice(0, home.lastIndexOf('#'))
478    if (!(hud.homes ??= []).includes(homeSession)) hud.homes.push(homeSession)
479    const view = ix.prompts.get(home)
480    if (view) {
481      ;(epic.prompts ??= {})[home] ??= view
482      if (task && ev.event_type === 'dispatch_decision') {
483        const mine = ((epic.taskPrompts ??= {})[task] ??= [])
484        if (!mine.includes(home)) mine.push(home)
485      }
486    }
487  }
488
489  if (sid && ev.cli_session_id === sid) epic.isMine = true
490  if (ts > epic.lastTs) epic.lastTs = ts
491  const wave = ev.session_id ? waveLabel(ev.session_id, epicId) : null
492  if (wave) {
493    epic.waveMax = Math.max(epic.waveMax, parseInt(wave.slice(1), 10))
494    // `??=`: a hud persisted before waves were kept has none
495    const w = ((epic.waves ??= {})[wave] ??= { since: ts, seen: ts, tasks: [] })
496    // after a pause the wave resumed: the band times this run, not the session
497    if (ts && w.seen && ts - w.seen > WAVE_IDLE_MS) w.since = ts
498    // an earlier event folded late moves the run's start back, never into a run before a pause
499    else if (ts && (!w.since || (ts < w.since && w.since - ts <= WAVE_IDLE_MS))) w.since = ts
500    if (ts > w.seen) w.seen = ts
501    // a finding names the task it is filed or deferred against, often one another wave works
502    const works = task && !isReserved(epicId, task) && !ev.event_type.startsWith('finding-')
503    if (works && !w.tasks.includes(task)) w.tasks.push(task)
504    // the last wave to touch a task owns it: a takeover hands it on
505    if (works) (epic.taskWave ??= {})[task] = wave
506  }
507
508  switch (ev.event_type) {
509    case 'task-added': {
510      if (!task || isReserved(epicId, task)) break
511      const prev = epic.tasks[task]
512      const title = str(p.title) ?? str(p.objective) ?? task
513      const origin = str(p.origin) ?? 'user'
514      // an amendment re-emits the task with its new version; a terminal status still stands (D-18b)
515      const planVersion = typeof p.plan_version === 'number' ? p.plan_version : (prev?.planVersion ?? null)
516      if (!prev) epic.tasks[task] = { status: str(p.task_status) ?? 'todo', title, origin, planVersion }
517      else {
518        prev.title = title
519        prev.origin = origin
520        prev.planVersion = planVersion
521        if (!TERMINAL.has(prev.status)) prev.status = str(p.task_status) ?? 'todo'
522      }
523      const asked = reachPrompt(hud, epic, ref)
524      if (asked) {
525        const mine = ((epic.taskPrompts ??= {})[task] ??= [])
526        if (!mine.includes(asked)) mine.push(asked)
527      }
528      break
529    }
530    case 'wave-admitted': {
531      const ids = Array.isArray(p.task_ids) ? p.task_ids.filter((x): x is string => typeof x === 'string').map(x => norm(epicId, x)) : []
532      epic.admitted += 1
533      epic.waveTaskIds = ids
534      const admission: AdmissionView = { ts, taskIds: ids, prompt: reachPrompt(hud, epic, ref), waveMaxAt: epic.waveMax }
535      ;(epic.admissions ??= []).push(admission)
536      // a wave admitted after a goal check: the close turned back into work
537      delete epic.isClosing
538      for (const id of ids) {
539        ensureRow(epic, epicId, id)
540        setStatus(epic, id, 'ready')
541      }
542      const b = p.budget as Record<string, unknown> | undefined
543      if (b && typeof b.cap_tokens === 'number') {
544        epic.budget = { cap: b.cap_tokens, projected: typeof b.projected_tokens === 'number' ? b.projected_tokens : 0, status: str(b.status) ?? '' }
545      }
546      // since Blacksmith sizes admissions per tier; an admission naming none (every log before 2026-10-08) keeps it
547      if (b && isTier(b.tier)) epic.admittedTier = b.tier
548      note(`▶ wave admitted · ${ids.length} task${ids.length === 1 ? '' : 's'}`, 'info', true)
549      break
550    }
551    case 'dispatch_decision': {
552      if (!task) break
553      const role = str(p.agent_role) ?? str(p.agent) ?? 'agent'
554      // every wave-runner is dispatched on `integration` from the epic session: its wave shows in `epic.waves`
555      if (role === WAVE_RUNNER) {
556        note(`${role} → ${short}`, 'info')
557        break
558      }
559      if (role !== 'coder') closeAgents(epic, task, a => a.role !== 'coder')
560      epic.agents[`${task}|${role}`] = { taskId: task, role, since: ts, model: str(p.model) ?? str(p.model_tier) ?? '' }
561      if (!isReserved(epicId, task)) setStatus(epic, task, 'in-progress')
562      note(`${role} → ${short}`, 'info')
563      break
564    }
565    case 'task-result-recorded': {
566      if (!task) break
567      const role = str(p.agent_role) ?? str(p.agent)
568      if (role && epic.agents[`${task}|${role}`]) delete epic.agents[`${task}|${role}`]
569      else {
570        const newest = Object.entries(epic.agents).filter(([, a]) => a.taskId === task).sort((x, y) => y[1].since - x[1].since)[0]
571        if (newest) delete epic.agents[newest[0]]
572      }
573      note(`${role ?? 'agent'} ${str(p.run_status) ?? 'done'} ${short}`, p.run_status === 'dead' ? 'bad' : 'ok')
574      break
575    }
576    case 'judge-reported': {
577      if (!task) break
578      const role = str(p.agent_role) ?? 'judge'
579      delete epic.agents[`${task}|${role}`]
580      const n = typeof p.finding_count === 'number' ? p.finding_count : 0
581      note(`${role} reported ${short} · ${n} finding${n === 1 ? '' : 's'}`, n > 0 ? 'warn' : 'ok')
582      break
583    }
584    case 'grader-verdict': {
585      if (!task) break
586      delete epic.agents[`${task}|grader`]
587      note(`grader ${str(p.overall) ?? str(p.verdict) ?? 'verdict'} ${short}`, p.overall === 'pass' || p.verdict === 'met' ? 'ok' : 'warn')
588      break
589    }
590    case 'judge-verdict': {
591      if (!task) break
592      const role = str(p.agent) ?? str(p.agent_role) ?? 'verifier'
593      delete epic.agents[`${task}|${role}`]
594      // a verdict informs; it is neither a pass nor a failure
595      note([role, str(p.verdict), short].filter(Boolean).join(' '), 'info')
596      break
597    }
598    case 'gate-outcome': {
599      if (!task) break
600      ensureRow(epic, epicId, task)
601      closeAgents(epic, task, a => a.role !== 'coder' && a.role !== 'tester')
602      const outcome = str(p.outcome) ?? ''
603      if (outcome === 'blocked') {
604        setStatus(epic, task, 'blocked')
605        note(`✖ gate fail ${short} (${str(p.reason) ?? 'blocked'})`, 'bad', true)
606      } else if (outcome === 'pass-with-waivers-pending') {
607        setStatus(epic, task, 'reviewing')
608        if (!epic.pendingWaivers.includes(task)) epic.pendingWaivers.push(task)
609        note(`⚑ waiver pending ${short}`, 'warn', true)
610      } else if (outcome === 'pass') {
611        setStatus(epic, task, 'merging')
612        epic.pendingWaivers = epic.pendingWaivers.filter(t => t !== task)
613        note(`gate pass ${short}`, 'ok')
614      }
615      break
616    }
617    case 'task-waiver-approved': {
618      if (task) epic.pendingWaivers = epic.pendingWaivers.filter(t => t !== task)
619      break
620    }
621    case 'wave-merged': {
622      const listed = Array.isArray(p.task_ids) ? p.task_ids.filter((x): x is string => typeof x === 'string') : []
623      const ids = (listed.length ? listed : task ? [task] : []).map(x => norm(epicId, x))
624      for (const id of ids) {
625        ensureRow(epic, epicId, id)
626        // mirrors Blacksmith projector.ts wave-merged: the merge completes the task over any status, escalated included
627        setStatus(epic, id, 'completed', true)
628        closeAgents(epic, id)
629        dropWaits(epic, id)
630        doneAt(epic, id, ts)
631      }
632      // the wave merged by then: Past keeps the prompts up to it (pastModel)
633      const w = wave ? epic.waves?.[wave] : undefined
634      if (w && ids.length && ts > (w.merged ?? 0)) w.merged = ts
635      if (ids.length) note(`✔ merged ${ids.map(shortTask).join(', ')}`, 'ok', true)
636      break
637    }
638    case 'task-superseded': {
639      if (!task) break
640      // mirrors Blacksmith projector.ts task-superseded: a completed task superseded later is superseded,
641      // so an amendment naming it waits on its successor
642      ensureRow(epic, epicId, task)
643      setStatus(epic, task, 'superseded', true)
644      closeAgents(epic, task)
645      dropWaits(epic, task)
646      doneAt(epic, task, ts)
647      note(`superseded ${short}`, 'info')
648      break
649    }
650    case 'error-logged': {
651      const sev = sevCode(p.severity)
652      const error = str(p.error) ?? 'error'
653      // a deliberate divergence: Blacksmith projector.ts touch() mints from error refs, the mod moves existing rows only.
654      // The refs that would mint are audit run ids (`20260914-5fe9088c.code-quality`), `lab-audit-1-planner` and a bare
655      // epic id; as rows they would inflate the counts and keep a wave live. Every real error naming a real task comes after
656      // its wave-admitted but one: web-audit-2's budget-exceeded on task-8, 24 min before it (Blacksmith showed it blocked).
657      const refs = taskRefs(epic, ev).filter(id => !isReserved(epicId, id))
658      const role = str(p.agent_role)
659      if (task && role) delete epic.agents[`${task}|${role}`]
660      // only an exact S3-minor or S4-nit leaves the task; a missing severity moves it like a major one
661      if (!NOTE_ONLY.has(str(p.severity) ?? '')) {
662        for (const id of refs) setStatus(epic, id, error.startsWith('coordination.') ? 'escalated' : 'blocked')
663        note(`✖ ${sev || 'S?'} ${error} ${refs.map(shortTask).join(', ')}`.trim(), 'bad', true)
664      } else note(`${sev || 'S?'} ${error} ${refs.map(shortTask).join(', ')}`.trim(), 'warn')
665      break
666    }
667    case 'quorum-decision': {
668      // one case per subject + identity, latest outcome wins: mirrors Blacksmith quorumEscalations.ts
669      const subject = subjectOf(p)
670      const taskId = str(p.task_id) ?? str(ev.task_id) ?? '(no task)'
671      const key = `${subject}:${subject === 'finding' ? (str(p.fingerprint) ?? taskId) : taskId}`
672      if (p.outcome === 'escalate') {
673        const of = taskId === '(no task)' ? '' : norm(epicId, taskId)
674        epic.escalations[key] = of
675        note(`⚑ escalation ${of ? shortTask(of) : subject} (${str(p.escalation_reason) ?? 'escalate'})`, 'warn', true)
676      } else delete epic.escalations[key]
677      break
678    }
679    case 'finding-raised': {
680      const id = str(p.finding_id)
681      if (!id) break
682      const sev = str(p.severity) ?? ''
683      epic.openFindings[id] = { severity: sev, status: str(p.finding_status) ?? 'raised' }
684      const code = sevCode(sev)
685      if (code === 'S1' || code === 'S2') note(`✖ ${code} finding ${short}`, 'bad', code === 'S1')
686      break
687    }
688    case 'finding-transitioned': {
689      const id = str(p.finding_id)
690      const to = str(p.to_status) ?? ''
691      if (!CLOSED_FINDING.has(to)) {
692        const open = id ? epic.openFindings[id] : undefined
693        if (open && to) open.status = to
694        // the amendment's obligation, last one carried wins: mirrors Blacksmith findings.ts (a 0 version is dropped there too)
695        if (open && Array.isArray(p.amends_task_ids)) open.amendsTaskIds = p.amends_task_ids.map(x => (typeof x === 'string' ? x : ''))
696        if (open && typeof p.amends_plan_version === 'number' && p.amends_plan_version) open.amendsPlanVersion = p.amends_plan_version
697        break
698      }
699      const fp = str(p.fingerprint)
700      for (const k of [id, fp]) if (k) delete epic.openFindings[k]
701      // a mod-side courtesy, not Blacksmith behaviour: a closed finding's case stops waiting on you
702      if (fp) delete epic.escalations[`finding:${fp}`]
703      break
704    }
705    case 'plan-version-created': {
706      if (p.epic_id !== epicId) break
707      // a plan amended after a goal check (web-ux-1 v12): the close turned back into planning work
708      delete epic.isClosing
709      const succ = (epic.successors ??= {})
710      const pair = (from: unknown, to: unknown) => {
711        if (typeof from === 'string' && typeof to === 'string') succ[norm(epicId, from)] = norm(epicId, to)
712      }
713      // mirrors Blacksmith spec.ts taskSuccessors: a present `successors` key wins, even empty or null
714      if (p.successors !== undefined) {
715        if (p.successors && typeof p.successors === 'object') for (const [from, to] of Object.entries(p.successors)) pair(from, to)
716        break
717      }
718      const diff = p.diff as Record<string, unknown> | null | undefined
719      const gone = diff && typeof diff === 'object' ? diff.superseded : null
720      const added = diff && typeof diff === 'object' ? diff.added : null
721      if (Array.isArray(gone) && Array.isArray(added) && gone.length === 1 && added.length === 1 && gone[0] !== added[0]) pair(gone[0], added[0])
722      break
723    }
724    case 'spec-change-proposed': {
725      if (!epic.pendingSpecChanges.includes(ref)) epic.pendingSpecChanges.push(ref)
726      note('⚑ spec change proposed', 'warn', true)
727      break
728    }
729    case 'spec-change-decided': {
730      const id = str(p.proposal_id)
731      if (id) epic.pendingSpecChanges = epic.pendingSpecChanges.filter(r => r !== id)
732      note(`spec change ${str(p.decision) ?? 'decided'}`, 'info')
733      break
734    }
735    case 'goal-check-recorded': {
736      // Blacksmith runs the goal check only on the close path (11 of 11 closed epics, none elsewhere)
737      if (p.epic_id === epicId) epic.isClosing = true
738      break
739    }
740    case 'epic-closed': {
741      epic.isClosed = true
742      epic.agents = {}
743      note(`✔ epic ${epicId} closed`, 'ok', true)
744      break
745    }
746  }
747  return notices
748}
749
750/** The first non-superseded row a superseded task's successors lead to; undefined when a hop is missing or the chain cycles. Mirrors Blacksmith epic.ts resolveSupersededRow. */
751function successorRow(epic: EpicView, id: string): TaskView | undefined {
752  const seen = new Set([id])
753  for (let at = id; ;) {
754    const next = epic.successors?.[at]
755    if (next === undefined || seen.has(next)) return undefined
756    seen.add(next)
757    const row = epic.tasks[next]
758    if (!row || row.status !== 'superseded') return row
759    at = next
760  }
761}
762
763/**
764 * An amend-pending finding Blacksmith counts discharged (D-127 Part B): it names
765 * tasks and a version, and every task (or the successor of a superseded one)
766 * landed completed or waived at or past that version. Ids compare bare, which
767 * `norm` gives, since the plan side and the event side spell them either way.
768 */
769export function isAmendLanded(epic: EpicView, f: FindingView): boolean {
770  const ids = f.amendsTaskIds
771  const version = f.amendsPlanVersion
772  if (f.status !== 'amend-pending' || !ids?.length || typeof version !== 'number') return false
773  return ids.every(raw => {
774    if (!str(raw)) return false
775    const id = norm(epic.epicId, raw)
776    const row = epic.tasks[id]
777    const evidence = row?.status === 'superseded' ? (successorRow(epic, id) ?? row) : row
778    return !!evidence && LANDED.has(evidence.status) && evidence.planVersion != null && evidence.planVersion >= version
779  })
780}
781
782/**
783 * A wave is done once it touched a plan task and every plan task it still owns is terminal:
784 * a wave whose open tasks a later wave took over has nothing left to land.
785 */
786function isWaveDone(epic: EpicView, label: string, w: WaveView): boolean {
787  // an escalation followup, or an id no event minted a row for (only a dispatch or a result named it), is no work the wave lands;
788  // a minted row with no origin yet is a plan task
789  const plan = w.tasks.filter(id => {
790    const t = epic.tasks[id]
791    return !!t && t.origin !== 'escalation'
792  })
793  // `??`: a hud persisted before owners were kept has none, so the toucher owns its task
794  const open = plan.filter(id => (epic.taskWave?.[id] ?? label) === label && !TERMINAL.has(epic.tasks[id]?.status ?? ''))
795  return plan.length > 0 && open.length === 0
796}
797
798/** One wave-runner per wave session silent no longer than WAVE_IDLE_MS and not done, timed from its current run; none once the epic closed. */
799function waveRunners(epic: EpicView, now: number): AgentView[] {
800  if (epic.isClosed) return []
801  return Object.entries(epic.waves ?? {})
802    .filter(([label, w]) => now - w.seen <= WAVE_IDLE_MS && !isWaveDone(epic, label, w))
803    .map(([label, w]) => ({ taskId: label, role: WAVE_RUNNER, since: w.since, model: '' }))
804}
805
806export function summarize(epic: EpicView, now: number): Summary {
807  // a hud folded before waves were kept may hold a dispatch-keyed `integration|wave-runner`
808  const agents = Object.values(epic.agents).filter(a => a.role !== WAVE_RUNNER)
809  const dispatched = agents.filter(a => now - a.since <= STALE_MS)
810  const live = [...dispatched, ...waveRunners(epic, now)].sort((a, b) => b.since - a.since)
811  // Now prefers a worker: a wave-runner only coordinates
812  const head = live.find(a => a.role !== WAVE_RUNNER) ?? live[0]
813
814  const counts = { done: 0, review: 0, active: 0, todo: 0, superseded: 0, total: 0 }
815  for (const t of Object.values(epic.tasks)) {
816    if (t.origin === 'escalation') continue
817    if (t.status === 'superseded') counts.superseded += 1
818    // waived is done: mirrors Blacksmith queries.ts statusBucketForTaskStatus
819    else if (LANDED.has(t.status)) counts.done += 1
820    else if (t.status === 'reviewing' || t.status === 'merging') counts.review += 1
821    else if (t.status === 'todo' || t.status === 'ready') counts.todo += 1
822    else counts.active += 1
823  }
824  counts.total = counts.done + counts.review + counts.active + counts.todo
825
826  const findings = { S1: 0, S2: 0, S3: 0, S4: 0 }
827  const blockers = { total: 0, byStatus: {} as Record<string, number> }
828  let amendsLanded = 0
829  for (const f of Object.values(epic.openFindings)) {
830    // discharged, so neither open nor blocking; the fold keeps it until the close moves it to `amended`
831    if (isAmendLanded(epic, f)) {
832      amendsLanded += 1
833      continue
834    }
835    const code = sevCode(f.severity) as keyof typeof findings
836    if (!(code in findings)) continue
837    findings[code] += 1
838    if ((code === 'S1' || code === 'S2') && !epic.isClosed) {
839      blockers.total += 1
840      blockers.byStatus[f.status] = (blockers.byStatus[f.status] ?? 0) + 1
841    }
842  }
843  // a closed epic waits on nothing: Blacksmith leaves its cases open, the summary hides them
844  const waits = epic.isClosed
845    ? { waivers: 0, escalations: 0, specs: 0 }
846    : { waivers: epic.pendingWaivers.length, escalations: Object.keys(epic.escalations).length, specs: epic.pendingSpecChanges.length }
847
848  const busy = new Set(live.map(a => a.taskId))
849  const idle = (id: string) => {
850    const s = epic.tasks[id]?.status
851    return (s === 'todo' || s === 'ready') && !busy.has(id)
852  }
853  const nextId = epic.waveTaskIds.find(idle) ?? Object.keys(epic.tasks).find(id => epic.tasks[id]?.origin !== 'escalation' && idle(id))
854
855  return {
856    epicId: epic.epicId,
857    // the band's phase reads the same helper (phaseOf), so the status line and the band name one wave
858    wave: waveNumber(epic),
859    now: head ? { role: head.role, task: shortTask(head.taskId), elapsed: fmtElapsed(now - head.since) } : null,
860    agentCount: live.length,
861    roles: live.map(a => a.role),
862    staleCount: agents.length - dispatched.length,
863    agents: live,
864    next: nextId ? shortTask(nextId) : null,
865    isWaitingOnYou: live.length === 0 && waits.waivers + waits.escalations + waits.specs > 0,
866    counts,
867    progress: counts.total ? counts.done / counts.total : 0,
868    budget: epic.budget && epic.budget.cap > 0
869      ? { cap: epic.budget.cap, projected: epic.budget.projected, pct: Math.round((epic.budget.projected / epic.budget.cap) * 100) }
870      : null,
871    waits,
872    blockers,
873    amendsLanded,
874    findings,
875    isClosed: epic.isClosed,
876  }
877}
878
879/** Where an epic stands, read from its log: `label` is what the band prints. */
880export type Phase = {
881  kind: 'planning' | 'wave' | 'after' | 'closing' | 'closed'
882  /** waveNumber's answer, the number the status line prints too; null with neither an admission nor a wave session */
883  wave: number | null
884  /** `planning`, `wave 7`, `after wave 7`, `closing` or `closed` */
885  label: string
886}
887
888/** A plan task the fold still holds open: neither terminal nor an escalation follow-up. */
889function isOpenPlanTask(t: TaskView): boolean {
890  return t.origin !== 'escalation' && !TERMINAL.has(t.status)
891}
892
893/**
894 * The latest admission's wave number. A `wave-admitted` names no wave, so the highest-numbered wave session that runs
895 * it: one that worked one of its tasks at or after it, or one that started after it numbered at least the waveMax it
896 * landed on (`w1r` is 1, an older wave's `-land` session is not; two sessions on one admission, web-ux-3 w18 + w19,
897 * give 19). Before its session starts, the wave after the highest seen then; with no wave session at all (waves run
898 * inline in the epic session), the admission count. With no admission kept (a wave session file with no
899 * `wave-admitted`, or a hud persisted before admissions were kept): the highest wave session's number, else the
900 * admission count; 0 with neither. One source for the status line (summarize's `wave`) and the band (phaseOf).
901 */
902export function waveNumber(epic: EpicView): number {
903  const adm = epic.admissions?.at(-1)
904  if (!adm) return epic.waveMax > 0 ? epic.waveMax : epic.admitted
905  let best = 0
906  for (const [label, w] of Object.entries(epic.waves ?? {})) {
907    const n = parseInt(label.slice(1), 10)
908    const works = w.seen >= adm.ts && w.tasks.some(id => epic.waveTaskIds.includes(id))
909    // `since` restarts after an hour's pause, so an older wave resuming is told apart by its number
910    const opened = adm.waveMaxAt !== undefined && w.since >= adm.ts && n >= adm.waveMaxAt
911    if (works || opened) best = Math.max(best, n)
912  }
913  if (best > 0) return best
914  const before = adm.waveMaxAt ?? epic.waveMax
915  return before > 0 ? before + 1 : epic.admitted
916}
917
918/**
919 * The tasks of wave `n`: the latest admission's; with no `wave-admitted` folded (a wave session file read without its
920 * epic session's), the tasks the sessions numbered `n` worked.
921 */
922function waveIds(epic: EpicView, n: number | null): string[] {
923  if (epic.admitted > 0 || n === null) return epic.waveTaskIds
924  const ids = Object.entries(epic.waves ?? {}).filter(([label]) => parseInt(label.slice(1), 10) === n).flatMap(([, w]) => w.tasks)
925  return [...new Set(ids)]
926}
927
928/**
929 * The epic's phase, first match wins:
930 * - `closed`: an `epic-closed`;
931 * - `planning`: neither a `wave-admitted` nor a wave session yet;
932 * - `closing`: a `goal-check-recorded` with no `wave-admitted` or `plan-version-created` after it, open work or not
933 *   (lab-audit-1 and web-audit-7 checked their goal with a task still open);
934 * - `wave N`: a task of wave N is still open: the latest admission's tasks, or with no admission (a wave session file
935 *   with no `wave-admitted`, web-audit-5's) the tasks wave N's sessions worked;
936 * - `after wave N`: that wave's tasks are all terminal, other plan work is open;
937 * - `closing`: every plan task is terminal (the close path's reviews run next).
938 * Task status comes from the fold (`task-added`, `dispatch_decision`, `gate-outcome`, `error-logged`, `wave-merged`,
939 * `task-superseded`); N from waveNumber, the helper the status line's `wN` reads (summarize), so the two never differ.
940 */
941export function phaseOf(epic: EpicView): Phase {
942  const n = waveNumber(epic)
943  const wave = n > 0 ? n : null
944  if (epic.isClosed) return { kind: 'closed', wave, label: 'closed' }
945  if (wave === null) return { kind: 'planning', wave, label: 'planning' }
946  if (epic.isClosing) return { kind: 'closing', wave, label: 'closing' }
947  const open = (id: string) => {
948    const t = epic.tasks[id]
949    return !!t && !TERMINAL.has(t.status)
950  }
951  if (waveIds(epic, wave).some(open)) return { kind: 'wave', wave, label: `wave ${wave}` }
952  if (Object.values(epic.tasks).some(isOpenPlanTask)) return { kind: 'after', wave, label: `after wave ${wave}` }
953  return { kind: 'closing', wave, label: 'closing' }
954}
955
956// ── Tier: the effort tier the epic runs at ─────────────────────────────────────────────────────
957
958/** Blacksmith's effort tiers (effortTiers.ts EFFORT_TIERS). */
959export type Tier = 'small' | 'medium' | 'huge'
960
961/** tierOf's answer: the tier and where it came from, both null when nothing names one. */
962export type TierPick = { tier: Tier | null; source: 'admission' | 'plan' | null }
963
964const TIERS: readonly string[] = ['small', 'medium', 'huge']
965
966function isTier(v: unknown): v is Tier {
967  return typeof v === 'string' && TIERS.includes(v)
968}
969
970/**
971 * The epic's tier: the newest `wave-admitted` whose `payload.budget.tier` names one, which is the tier Blacksmith
972 * sized the budget for; else `planEffort`, the latest plan's raw `effort` (register reads it once per plan
973 * version: planDirOf, latestPlanName, planEffort); else none. The plan's effort can sit below the effective
974 * tier: epicBudget.ts budgetTierForPlan raises it to effort.yml's `security_floor` when the plan touches a
975 * security trigger ("a plan's raw `effort` is not its tier", budgets.ts), so only the admission's tier is exact.
976 */
977export function tierOf(epic: EpicView, planEffort: Tier | null = null): TierPick {
978  if (epic.admittedTier) return { tier: epic.admittedTier, source: 'admission' }
979  if (planEffort) return { tier: planEffort, source: 'plan' }
980  return { tier: null, source: null }
981}
982
983/**
984 * Where Blacksmith's plan.ts `latestPlan` looks for `epicId`'s plans: `<work root>/factory/specs/active/<epic>`
985 * (SPECS_ACTIVE_DIR), the work root being the one whose `state/events` holds the logs (STATE_EVENTS_DIR); null
986 * for a dir not named `state/events`, or an epic id that would leave the dir. A plan written with `--specs-dir`
987 * (a project whose plans sit under its own .blacksmith while its logs sit in the clone) is not there: the tier
988 * falls through to none.
989 */
990export function planDirOf(eventsDir: string, epicId: string): string | null {
991  const dir = eventsDir.replace(/\/+$/, '')
992  if (!dir.endsWith('/state/events') || !epicId || epicId.includes('/') || epicId === '.' || epicId === '..') return null
993  return `${dir.slice(0, -'/state/events'.length)}/factory/specs/active/${epicId}`
994}
995
996/** The latest plan file among a plan dir's names: the highest `plan-vN.json`, as plan.ts latestPlanVersion picks it; null when none. */
997export function latestPlanName(names: Iterable<string>): string | null {
998  let best: string | null = null
999  let max = -1
1000  for (const name of names) {
1001    const m = /^plan-v(\d+)\.json$/.exec(name)
1002    if (m && Number(m[1]) > max) {
1003      max = Number(m[1])
1004      best = name
1005    }
1006  }
1007  return best
1008}
1009
1010/** A plan file's `effort` when it names a tier; null for no effort, an unknown one, or text that is no JSON object. */
1011export function planEffort(text: string): Tier | null {
1012  try {
1013    const plan = JSON.parse(text) as unknown
1014    return plan && typeof plan === 'object' && isTier((plan as Record<string, unknown>).effort) ? ((plan as Record<string, unknown>).effort as Tier) : null
1015  } catch {
1016    return null
1017  }
1018}
1019
1020/** The highest plan version a `task-added` carried, 0 when none did: register re-reads the plan when it moves. */
1021export function planVersionOf(epic: EpicView): number {
1022  let max = 0
1023  for (const t of Object.values(epic.tasks)) if (t.planVersion !== null && t.planVersion > max) max = t.planVersion
1024  return max
1025}
1026
1027/** The pinned epic (closed or not), else the most recently active open epic this CLI session wrote to. */
1028export function pickEpic(hud: Hud, pinned: string | null): EpicView | null {
1029  if (pinned) return hud.epics[pinned] ?? null
1030  const mine = Object.values(hud.epics).filter(e => e.isMine && !e.isClosed && (Object.keys(e.tasks).length > 0 || e.admitted > 0))
1031  mine.sort((a, b) => b.lastTs - a.lastTs)
1032  return mine[0] ?? null
1033}
1034
1035/** Which of the two an epic on screen is: pinned, or this session's own. */
1036export type ViewKind = 'pinned' | 'own'
1037
1038/** The one place the drawing asks what to show: the pin, else this session's own open epic. */
1039export function pickView(hud: Hud, pinned: string | null): { epic: EpicView; kind: ViewKind } | null {
1040  if (pinned) {
1041    const epic = hud.epics[pinned]
1042    return epic ? { epic, kind: 'pinned' } : null
1043  }
1044  const own = pickEpic(hud, null)
1045  return own ? { epic: own, kind: 'own' } : null
1046}
1047
1048// ── Tab models: what each tab of the band lists, as plain data the drawing cuts to fit ─────────
1049
1050/** A list cut to a cap: the rows to draw and how many it left out, so the drawing can print `+N more`. */
1051export type Capped<T> = { rows: T[]; more: number }
1052
1053/** The first `cap` items (every one with Infinity, the default; none with 0 or less) and how many were left out. */
1054export function capped<T>(items: readonly T[], cap = Infinity): Capped<T> {
1055  const rows = items.slice(0, Number.isNaN(cap) ? items.length : Math.max(0, cap))
1056  return { rows, more: items.length - rows.length }
1057}
1058
1059/** Per-list caps a tab model takes; a cap left out lists everything. */
1060export type TabCaps = {
1061  /** task rows per list (Current, Next, each Past group) */
1062  rows?: number
1063  /** prompts per list (Current, Next, each Past group) */
1064  prompts?: number
1065  /** Past's wave groups */
1066  groups?: number
1067}
1068
1069/** One task as a tab lists it. */
1070export type TaskRow = {
1071  /** the epic-prefixed id */
1072  id: string
1073  /** shortTask(id), `task-17`: what the row draws for the id */
1074  short: string
1075  /** '' when the title is the id or the short id (the task-H rule: the row never draws the id twice) */
1076  title: string
1077  status: string
1078  /** the role of the newest live dispatch on it (no older than STALE_MS, never a wave-runner); null when none */
1079  role: string | null
1080  /** fmtElapsed since that dispatch; null when none */
1081  elapsed: string | null
1082}
1083
1084/** An operator prompt as a tab lists it, newest first: `ref` is its `<session id>#<line index>`. */
1085export type PromptRow = {
1086  ref: string
1087  ts: number
1088  text: string
1089  /** the first task (lowest number) the prompt led to, `task-16`; absent when no work descends from it */
1090  task?: string
1091  /** how many more tasks it led to; absent when it led to one or none */
1092  more?: number
1093}
1094
1095/**
1096 * Tokens spent against the cap. The log names one spend, the epic's: the newest `wave-admitted`'s `projected_tokens`,
1097 * which is measured tokens plus the declared cap of every dispatch nothing measured (Blacksmith waveBudget.ts,
1098 * budgetAlarm.ts's projection), so `basis: 'projected'`. No wave's own spend can be computed honestly: `wave_tokens`
1099 * is the admitted tasks' declared budgets, not a spend, and judges are never measured, so `scope` is always `'epic'`.
1100 */
1101export type Spend = { cap: number; projected: number; pct: number; scope: 'epic'; basis: 'projected' }
1102
1103/** The pane's and the band's row order: failing first, then in progress, review, ready, landed, superseded; an unknown status sorts as in progress. */
1104export const STATUS_RANK: Readonly<Record<string, number>> = {
1105  blocked: 0,
1106  escalated: 0,
1107  failed: 0,
1108  'in-progress': 1,
1109  reviewing: 2,
1110  merging: 2,
1111  ready: 3,
1112  todo: 3,
1113  completed: 4,
1114  waived: 4,
1115  superseded: 5,
1116}
1117
1118/** register.tsx taskNumber: the number after `task-`, an id with none last. */
1119function taskNo(id: string): number {
1120  const m = /task-(\d+)/.exec(id)
1121  return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER
1122}
1123
1124/** Sorts task ids as the pane sorts its rows: STATUS_RANK, then task number; a tie keeps the given order. */
1125function inPaneOrder(epic: EpicView, ids: readonly string[]): string[] {
1126  const rank = (id: string) => STATUS_RANK[epic.tasks[id]?.status ?? ''] ?? 1
1127  return [...ids].sort((a, b) => rank(a) - rank(b) || taskNo(a) - taskNo(b))
1128}
1129
1130/** summarize's live agents, newest first: open dispatches no older than STALE_MS, then one wave-runner per live wave. */
1131function liveAgents(epic: EpicView, now: number): AgentView[] {
1132  const dispatched = Object.values(epic.agents).filter(a => a.role !== WAVE_RUNNER && now - a.since <= STALE_MS)
1133  return [...dispatched, ...waveRunners(epic, now)].sort((a, b) => b.since - a.since)
1134}
1135
1136function taskRow(epic: EpicView, id: string, live: readonly AgentView[], now: number): TaskRow {
1137  const t = epic.tasks[id]
1138  const short = shortTask(id)
1139  const title = t?.title ?? id
1140  // `live` is newest first
1141  const agent = live.find(a => a.taskId === id && a.role !== WAVE_RUNNER)
1142  return {
1143    id,
1144    short,
1145    title: title === id || title === short ? '' : title,
1146    status: t?.status ?? 'todo',
1147    role: agent?.role ?? null,
1148    elapsed: agent ? fmtElapsed(now - agent.since) : null,
1149  }
1150}
1151
1152/**
1153 * The kept prompts `refs` name, each once, newest first; a ref with no kept prompt (a hud persisted before prompts were
1154 * kept) is left out, and so is one dated after `until` when it is given.
1155 */
1156function promptRows(epic: EpicView, refs: Iterable<string | null | undefined>, cap?: number, until?: number): Capped<PromptRow> {
1157  const kept = epic.prompts ?? {}
1158  const rows = distinctPrompts(refs).flatMap(ref => {
1159    const p = kept[ref]
1160    return p && (until === undefined || p.ts <= until) ? [promptRow(epic, ref, p)] : []
1161  })
1162  return capped(rows.sort((a, b) => b.ts - a.ts), cap)
1163}
1164
1165/** A kept prompt as a row, with the first task it led to and how many more. */
1166function promptRow(epic: EpicView, ref: string, p: PromptView): PromptRow {
1167  const ids = Object.entries(epic.taskPrompts ?? {}).filter(([, refs]) => refs.includes(ref)).map(([id]) => id).sort((a, b) => taskNo(a) - taskNo(b))
1168  const row: PromptRow = { ref, ts: p.ts, text: p.text }
1169  if (ids[0]) row.task = shortTask(ids[0])
1170  if (ids.length > 1) row.more = ids.length - 1
1171  return row
1172}
1173
1174function spendOf(epic: EpicView): Spend | null {
1175  const b = epic.budget
1176  if (!b || !(b.cap > 0)) return null
1177  return { cap: b.cap, projected: b.projected, pct: Math.round((b.projected / b.cap) * 100), scope: 'epic', basis: 'projected' }
1178}
1179
1180/** Status cells over `ids` as summarize counts them: escalation follow-ups and rows never minted left out, superseded out of the total. */
1181function cellsOf(epic: EpicView, ids: readonly string[]): Cells & { total: number } {
1182  const c = { done: 0, review: 0, active: 0, todo: 0, total: 0 }
1183  for (const id of new Set(ids)) {
1184    const t = epic.tasks[id]
1185    if (!t || t.origin === 'escalation' || t.status === 'superseded') continue
1186    if (LANDED.has(t.status)) c.done += 1
1187    else if (t.status === 'reviewing' || t.status === 'merging') c.review += 1
1188    else if (t.status === 'todo' || t.status === 'ready') c.todo += 1
1189    else c.active += 1
1190  }
1191  c.total = c.done + c.review + c.active + c.todo
1192  return c
1193}
1194
1195/** Every task any wave admitted: each admission's tasks, plus the latest wave's (all a hud persisted before admissions were kept has). */
1196function admittedIds(epic: EpicView): string[] {
1197  return [...epic.waveTaskIds, ...(epic.admissions ?? []).flatMap(a => a.taskIds)]
1198}
1199
1200/** Current's task ids: every task any wave admitted and every task a live dispatch works, minus landed and superseded ones, in pane order (failing first). */
types/index.d.ts 143 lines
1// The bs-mod contract: what the mod keeps in `$.state`, and the shapes its
2// pure fold (hooks/fold.ts) hands the drawing.
3
4export type Tone = 'ok' | 'bad' | 'warn' | 'info'
5
6export type TaskView = {
7  status: string
8  title: string
9  /** null until a `task-added` names the task: a row minted by an admission, gate, merge or supersede, as Blacksmith projector.ts touch() mints it */
10  origin: string | null
11  /** the plan version its last `task-added` carried, kept across a re-emit that omits it; null when none did */
12  planVersion: number | null
13}
14
15/** An open finding, plus the obligation an amend-pending move names (Blacksmith D-127). */
16export type FindingView = {
17  severity: string
18  status: string
19  /** the task ids the amendment must land, as the transition spelled them; a non-string entry is kept as '' */
20  amendsTaskIds?: string[]
21  /** the plan version every amended task must land at or past */
22  amendsPlanVersion?: number
23}
24
25export type AgentView = { taskId: string; role: string; since: number; model: string }
26
27/** One wave session (`<epic>-w4-<date>`): its wave-runner is live while the session writes (silent ≤ WAVE_IDLE_MS) and a task it owns is open. */
28export type WaveView = {
29  /** first event ts of the current run: the event that broke a silence over WAVE_IDLE_MS, else the session's first */
30  since: number
31  /** latest event ts seen in the session */
32  seen: number
33  /** epic-prefixed task ids its events carried, reserved refs (`integration`, `plan-vN`) left out */
34  tasks: string[]
35  /** ts of the newest `wave-merged` in the session; absent before it merged, and in a hud persisted before merges were timed */
36  merged?: number
37}
38
39export type Activity = { ts: number; text: string; tone: Tone }
40
41export type Budget = { cap: number; projected: number; status: string }
42
43/** An operator prompt some work descends from: its `user_prompt` ts and its text on one line, cut to PROMPT_CHARS plus `…`. */
44export type PromptView = { ts: number; text: string }
45
46/** One `wave-admitted`: when, the epic-prefixed task ids it admitted, and the ref of the prompt its causal chain reaches (null when none). */
47export type AdmissionView = {
48  ts: number
49  taskIds: string[]
50  prompt: string | null
51  /** the epic's waveMax when the admission landed: a wave session numbered at least this, started after it, runs it; absent in admissions kept before */
52  waveMaxAt?: number
53}
54
55export type EpicView = {
56  epicId: string
57  /** wave-admitted events seen for the epic */
58  admitted: number
59  /** highest wave number in a wave session id (`<epic>-w7-<date>`, `-wave-3-`, `-w2-takeover-`), 0 when none */
60  waveMax: number
61  waveTaskIds: string[]
62  tasks: Record<string, TaskView>
63  /** open dispatches, keyed `<task id>|<role>`; wave-runners live in `waves`, not here */
64  agents: Record<string, AgentView>
65  /** one per wave session, keyed by wave label: `w4`, `w1r`, `w3` for `wave-3`; a takeover or land session of wave 6 is `w6` */
66  waves: Record<string, WaveView>
67  /** task id → label of the wave that touched it last, its owner: a takeover moves a task to the later wave */
68  taskWave: Record<string, string>
69  budget: Budget | null
70  /** the tier (`small` / `medium` / `huge`) of the newest `wave-admitted` whose `budget.tier` names one; absent before one did */
71  admittedTier?: 'small' | 'medium' | 'huge'
72  /** task ids whose gate passed with waivers pending */
73  pendingWaivers: string[]
74  /** open quorum escalations, keyed `<finding|plan|epic>:<fingerprint or task id>` like Blacksmith quorumEscalations.ts, valued by task id ('' when none) */
75  escalations: Record<string, string>
76  /** open findings: severity (`S2-major`), the status it last moved to (`amend-pending`) and any amendment obligation */
77  openFindings: Record<string, FindingView>
78  /** superseded task id → the id that replaced it, both epic-prefixed, from `plan-version-created` */
79  successors: Record<string, string>
80  /** spec-change proposals with no decision, as `<session id>#<line index>` */
81  pendingSpecChanges: string[]
82  activity: Activity[]
83  /** the prompts some admission or task-added reached, keyed by `<session id>#<line index>`; absent in a hud persisted before prompts were kept */
84  prompts?: Record<string, PromptView>
85  /** every `wave-admitted` in log order; absent in a hud persisted before prompts were kept */
86  admissions?: AdmissionView[]
87  /** task id → the distinct prompt refs its `task-added` events reached, first seen first; a task none reached has no entry */
88  taskPrompts?: Record<string, string[]>
89  /** task id → ts of the newest `wave-merged` or `task-superseded` naming it; absent in a hud persisted before these were timed */
90  doneAt?: Record<string, number>
91  lastTs: number
92  isClosed: boolean
93  /** a `goal-check-recorded` came and no `wave-admitted` or `plan-version-created` since: the close is under way; absent otherwise */
94  isClosing?: boolean
95  /** an event of this CLI session belongs to the epic */
96  isMine: boolean
97}
98
99/** A prompt of this CLI session's home log, as the band keeps it: `ref` is its `<session id>#<line index>`. */
100export type HomePrompt = PromptView & { ref: string }
101
102export type Hud = {
103  epics: Record<string, EpicView>
104  /** the newest 2 `user_prompt`s of this CLI session's own home log (`prompts-<cli id>`), newest first; absent before one was folded */
105  homePrompts?: HomePrompt[]
106  /** the home logs (`prompts-<id>`) some epic's session-start or dispatch_decision named through `parent_prompt_id`, so register can read them */
107  homes?: string[]
108}
109
110/** One task of the session's own task list (TaskCreate / TaskUpdate / TaskList / TodoWrite), as the band keeps it. */
111export type TaskItem = { id: string; subject: string; activeForm: string | null; status: string }
112
113/** The background work the main loop started (a shell or a monitor, by id) and the ids a notification or TaskStop ended, each once. */
114export type BgState = { started: Record<string, 'shell' | 'monitor'>; ended: string[] }
115
116/** An epic's latest plan `effort` where Blacksmith latestPlan looks by default, read once at `version` (fold.ts planVersionOf); `tier` null when no plan there names one. */
117export type PlanTier = { version: number; tier: 'small' | 'medium' | 'huge' | null }
118
119/** The band's four tabs, in the order the tab row draws them. */
120export type BandTab = 'overview' | 'current' | 'next' | 'past'
121
122declare module 'claude-code' {
123  interface PluginState {
124    'bs-mod': {
125      hud: Hud
126      pinned: string | null
127      isHidden: boolean
128      /** the clock's minute, written each tick it changes, so elapsed times redraw */
129      minute: number
130      /** per shown epic with no admission tier: its plan's effort (fold.ts tierOf's fallback), cached so the plan file is read once per plan version */
131      planTiers: Record<string, PlanTier>
132      /** the band's active tab; survives a reload, Overview by default */
133      tab: BandTab
134      /** the `/config` theme, read at session start and on each theme write; null when unread, so the theme keys draw (fold.ts paletteOf) */
135      theme: string | null
136      /** the session's own task list, folded from the main loop's task tool calls; empty when it has none */
137      taskList: TaskItem[]
138      /** the background work started and ended: the idle band's progress when there is no task list */
139      bg: BgState
140    }
141  }
142}
143