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

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.
o, c, n and p switch tabs while the band has focus./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.| Command | Does |
|---|---|
/bs-mod | Opens 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 auto | Drops the pin, so the HUD follows this session again. |
/bs-mod off / /bs-mod on | Hides or shows the band. |
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.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.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.
hooks/register.tsx 1349 lines1// 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 lines1// 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 lines1// 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