@a-t-h-i/bot-lobby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +406 -0
  3. package/package.json +46 -0
  4. package/prompts/backend.md +28 -0
  5. package/prompts/designer.md +33 -0
  6. package/prompts/global.md +42 -0
  7. package/prompts/master.md +107 -0
  8. package/prompts/qa.md +26 -0
  9. package/prompts/researcher.md +32 -0
  10. package/prompts/reviewer.md +43 -0
  11. package/prompts/scout.md +36 -0
  12. package/prompts/worker.md +49 -0
  13. package/src/agents/backend.ts +10 -0
  14. package/src/agents/designer.ts +10 -0
  15. package/src/agents/qa.ts +10 -0
  16. package/src/agents/registry.ts +14 -0
  17. package/src/execution/agent-runner.ts +150 -0
  18. package/src/execution/git.ts +42 -0
  19. package/src/execution/pi-runner.ts +292 -0
  20. package/src/index.ts +13 -0
  21. package/src/knowledge/compactor.ts +135 -0
  22. package/src/knowledge/paths.ts +43 -0
  23. package/src/knowledge/selector.ts +82 -0
  24. package/src/knowledge/store.ts +111 -0
  25. package/src/master/decisions.ts +53 -0
  26. package/src/master/master.ts +298 -0
  27. package/src/master/research.ts +98 -0
  28. package/src/master/synthesis.ts +57 -0
  29. package/src/pi/activity.ts +60 -0
  30. package/src/pi/commands.ts +268 -0
  31. package/src/pi/events.ts +76 -0
  32. package/src/pi/expressions.ts +101 -0
  33. package/src/pi/mascot-art.ts +252 -0
  34. package/src/pi/notify.ts +42 -0
  35. package/src/pi/quiet.ts +46 -0
  36. package/src/pi/settings-ui.ts +258 -0
  37. package/src/pi/tool-renderers.ts +121 -0
  38. package/src/pi/tools.ts +158 -0
  39. package/src/pi/ui.ts +227 -0
  40. package/src/pi/zen-large.ts +483 -0
  41. package/src/pi/zen-metrics.ts +80 -0
  42. package/src/pi/zen.ts +460 -0
  43. package/src/prompts/compiler.ts +50 -0
  44. package/src/prompts/loader.ts +20 -0
  45. package/src/roles/markdown.ts +64 -0
  46. package/src/roles/registry.ts +16 -0
  47. package/src/roles/researcher.ts +83 -0
  48. package/src/roles/reviewer.ts +65 -0
  49. package/src/roles/scout.ts +61 -0
  50. package/src/roles/worker.ts +94 -0
  51. package/src/schemas/agent.ts +39 -0
  52. package/src/schemas/configuration.ts +107 -0
  53. package/src/schemas/findings.ts +110 -0
  54. package/src/schemas/task.ts +113 -0
  55. package/src/state/persistence.ts +232 -0
  56. package/src/state/project.ts +99 -0
  57. package/src/state/task-state.ts +22 -0
  58. package/src/text.ts +51 -0
  59. package/src/workflow/approvals.ts +45 -0
  60. package/src/workflow/transitions.ts +41 -0
  61. package/src/workflow/workflow.ts +771 -0
@@ -0,0 +1,483 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ BAR,
4
+ ORACLE_COLORS,
5
+ ORACLE_FRAMES,
6
+ ORACLE_WORDS,
7
+ SLOT_FRAMES,
8
+ SLOT_LABELS,
9
+ SLOT_STATE_COLORS,
10
+ SLOT_STATE_GLYPHS,
11
+ SLOT_STATE_WORDS,
12
+ SPIN_FRAMES,
13
+ TOWER,
14
+ TOWER_DOOR,
15
+ type OracleFrame,
16
+ type OraclePose,
17
+ type PanelColor,
18
+ type SlotId,
19
+ type SlotState,
20
+ } from "./mascot-art.ts";
21
+ import type { PanelTheme } from "./zen.ts";
22
+
23
+ /**
24
+ * The large animated zen scene: a header box, the pulsing oracle tower with its
25
+ * tree connector, the four animated agent columns (face, label, status word and
26
+ * elapsed rows), and the TASKS checklist spanning the full scene width (there is
27
+ * no LOG window).
28
+ *
29
+ * Pure layout: every colour comes from the optional `PanelTheme`, every frame
30
+ * index comes from the caller, and nothing here reads the clock or a random
31
+ * source. Below `LARGE_MIN_WIDTH` the caller renders the compact strip instead
32
+ * of calling this module.
33
+ */
34
+
35
+ /** The large scene only renders at or above this width. */
36
+ export const LARGE_MIN_WIDTH = 72;
37
+
38
+ /** Fixed scene width; the box, tower and agent strip are all centered on it. */
39
+ export const SCENE_WIDTH = 63;
40
+
41
+ export const MAX_TASK_ROWS = 6;
42
+ export const MAX_LARGE_LINES = 35;
43
+
44
+ /** One agent column: state, caller-selected animation frame, live activity and elapsed. */
45
+ export interface LargeSlot {
46
+ id: SlotId;
47
+ label: string;
48
+ status: SlotState;
49
+ frame: number;
50
+ /** One-word tool activity while working; absent reads as "working". */
51
+ activity?: string;
52
+ /** Run duration label rendered under the status row; "—" when idle. */
53
+ elapsedLabel: string;
54
+ }
55
+
56
+ export interface LargeTaskRow {
57
+ text: string;
58
+ status: "done" | "current" | "pending";
59
+ }
60
+
61
+ /** Everything the large scene draws; assembled by the caller from task and runs. */
62
+ export interface LargeSceneInput {
63
+ taskId: string;
64
+ taskTitle: string;
65
+ state: string;
66
+ elapsedLabel: string;
67
+ quietHint: string;
68
+ /** Caller-injected spinner frame; the scene reads no clock. */
69
+ tick: number;
70
+ done: number;
71
+ total: number;
72
+ slots: readonly LargeSlot[];
73
+ tasks: readonly LargeTaskRow[];
74
+ oracle: { pose: OraclePose; frame: number };
75
+ /** Master's live tool activity for the oracle spinner; absent reads "working". */
76
+ oracleActivity?: string;
77
+ /** Approval/blocked line; never dropped when present. */
78
+ alert?: string;
79
+ /** Severity of the alert line: approvals are `warning`, blocked is `error`. */
80
+ alertKind?: "warning" | "error";
81
+ /** Name over the tower door, `TOWER_DOOR` when omitted. */
82
+ doorLabel?: string;
83
+ }
84
+
85
+ export const SLOT_CELL = 15;
86
+ const SLOT_GAP = 1;
87
+ const SLOT_ROWS = 4;
88
+ const BRANCH_ROWS = 3;
89
+ const SECTION_MIN = 2;
90
+ const MAX_SLOTS = 4;
91
+ const CONTENT = SCENE_WIDTH - 4;
92
+ const TASKS_CELL = CONTENT;
93
+
94
+ const TASK_COLORS: Record<LargeTaskRow["status"], PanelColor> = {
95
+ done: "success",
96
+ current: "accent",
97
+ pending: "dim",
98
+ };
99
+
100
+ const TOWER_ROWS: Record<TowerSize, number> = {
101
+ full: TOWER.rows.length + 1,
102
+ small: TOWER.smallRows.length + 1,
103
+ none: 0,
104
+ };
105
+
106
+ const TOWER_PATTERN = new RegExp(
107
+ `(${Object.values(TOWER.tokens).map((token) => token.replace(/([{}])/g, "\\$1")).join("|")})`,
108
+ );
109
+
110
+ type TowerSize = "full" | "small" | "none";
111
+
112
+ interface Cell {
113
+ offset: number;
114
+ text: string;
115
+ paint?: (text: string) => string;
116
+ }
117
+
118
+ interface LayoutPlan {
119
+ tower: TowerSize;
120
+ branch: boolean;
121
+ strip: boolean;
122
+ section: boolean;
123
+ /** Entry rows the plan guarantees when a section is present. */
124
+ minEntries: number;
125
+ }
126
+
127
+ /**
128
+ * Rich to sparse. The section outranks the tower: the tower only grows once the
129
+ * section can keep all `MAX_TASK_ROWS` entry rows, so a taller terminal never
130
+ * removes or shrinks TASKS.
131
+ */
132
+ const PLANS: readonly LayoutPlan[] = [
133
+ { tower: "full", branch: true, strip: true, section: true, minEntries: MAX_TASK_ROWS },
134
+ { tower: "small", branch: true, strip: true, section: true, minEntries: MAX_TASK_ROWS },
135
+ { tower: "small", branch: true, strip: true, section: true, minEntries: SECTION_MIN },
136
+ { tower: "small", branch: true, strip: true, section: false, minEntries: 0 },
137
+ { tower: "small", branch: false, strip: true, section: false, minEntries: 0 },
138
+ { tower: "none", branch: false, strip: true, section: false, minEntries: 0 },
139
+ ];
140
+
141
+ const EMPTY_PLAN: LayoutPlan = { tower: "none", branch: false, strip: false, section: false, minEntries: 0 };
142
+
143
+ /**
144
+ * Scene lines for the large tier, or nothing below `LARGE_MIN_WIDTH`.
145
+ *
146
+ * `budget` is a caller-computed LINE budget (zen.ts derives it from the
147
+ * terminal height), not a terminal row count. The header box and the alert are
148
+ * the fixed frame and are always drawn, so a budget below the fixed frame still
149
+ * returns a bounded, width-safe scene instead of dropping the alert.
150
+ */
151
+ export function largeLines(
152
+ input: LargeSceneInput,
153
+ width: number,
154
+ budget: number,
155
+ theme?: PanelTheme,
156
+ ): string[] {
157
+ if (width < LARGE_MIN_WIDTH) return [];
158
+ const head = boxLines(input, width, theme);
159
+ const alert = input.alert ? [alertLine(input.alert, width, theme, input.alertKind ?? "warning")] : [];
160
+ const avail = Math.max(0, lineBudget(budget) - head.length - alert.length);
161
+ return [...head, ...alert, ...bodyLines(input, width, theme, avail)];
162
+ }
163
+
164
+ /** Clamp a caller line budget to the scene cap, ignoring non-finite input. */
165
+ function lineBudget(budget: number): number {
166
+ return clamp(Number.isFinite(budget) ? Math.floor(budget) : 0, 0, MAX_LARGE_LINES);
167
+ }
168
+
169
+ function paint(text: string, color: PanelColor, theme?: PanelTheme, bold = false): string {
170
+ if (!theme || text.length === 0) return text;
171
+ const painted = theme.fg(color, text);
172
+ return bold ? theme.bold(painted) : painted;
173
+ }
174
+
175
+ function statusPaint(status: SlotState, theme?: PanelTheme): ((text: string) => string) | undefined {
176
+ if (!theme) return undefined;
177
+ const style = SLOT_STATE_COLORS[status];
178
+ return (text) => paint(text, style.color, theme, style.bold);
179
+ }
180
+
181
+ function labelPaint(status: SlotState, theme?: PanelTheme): ((text: string) => string) | undefined {
182
+ if (!theme) return undefined;
183
+ return status === "working"
184
+ ? (text) => theme.bold(theme.fg("accent", text))
185
+ : (text) => theme.fg("dim", text);
186
+ }
187
+
188
+ function padTo(text: string, width: number): string {
189
+ return truncateToWidth(text, width, "", true);
190
+ }
191
+
192
+ function centre(text: string, width: number): string {
193
+ return padTo(" ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) + text, width);
194
+ }
195
+
196
+ function mod(index: number, length: number): number {
197
+ return length <= 0 ? 0 : ((index % length) + length) % length;
198
+ }
199
+
200
+ function clamp(value: number, min: number, max: number): number {
201
+ return Math.min(Math.max(value, min), max);
202
+ }
203
+
204
+ function progress(done: number, total: number): number {
205
+ if (!(total > 0) || !(done > 0)) return 0;
206
+ return clamp(Math.round((done / total) * 100), 0, 100);
207
+ }
208
+
209
+ function sceneLeft(width: number): number {
210
+ return Math.max(0, Math.floor((width - SCENE_WIDTH) / 2));
211
+ }
212
+
213
+ function sceneCentre(width: number): number {
214
+ return Math.floor((width - 1) / 2);
215
+ }
216
+
217
+ /** Place a row of at most `SCENE_WIDTH` columns on the scene's centre column. */
218
+ function place(row: string, width: number): string {
219
+ return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - visibleWidth(row)) / 2))) + row;
220
+ }
221
+
222
+ function alertLine(
223
+ alert: string,
224
+ width: number,
225
+ theme?: PanelTheme,
226
+ kind: "warning" | "error" = "warning",
227
+ ): string {
228
+ const left = sceneLeft(width);
229
+ const line = paint(truncateToWidth(`! ${alert}`, width - left, "…"), kind, theme, true);
230
+ return " ".repeat(left) + line;
231
+ }
232
+
233
+ function boxLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
234
+ const rows = [topBorder(input, theme), barRow(input, theme), metaRow(input, theme), bottomBorder(theme)];
235
+ return rows.map((row) => place(row, width));
236
+ }
237
+
238
+ function topBorder(input: LargeSceneInput, theme?: PanelTheme): string {
239
+ const lead = "┌─ BOT-LOBBY ── ";
240
+ const label = truncateToWidth(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
241
+ const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - visibleWidth(lead) - visibleWidth(label)));
242
+ return paint(lead, "muted", theme) + paint(label, "accent", theme, true) + paint(`${tail}┐`, "muted", theme);
243
+ }
244
+
245
+ function bottomBorder(theme?: PanelTheme): string {
246
+ return paint(`└${"─".repeat(SCENE_WIDTH - 2)}┘`, "muted", theme);
247
+ }
248
+
249
+ /** Metadata row: elapsed, quiet hint and the task id (the title lives in the border). */
250
+ function metaRow(input: LargeSceneInput, theme?: PanelTheme): string {
251
+ const meta = `⏱ ${input.elapsedLabel} · ${input.quietHint} · ${input.taskId}`;
252
+ return boxRow(paint(padTo(meta, CONTENT), "dim", theme), theme);
253
+ }
254
+
255
+ function barRow(input: LargeSceneInput, theme?: PanelTheme): string {
256
+ const percent = progress(input.done, input.total);
257
+ const filled = Math.round((percent / 100) * BAR.cells);
258
+ const counts = ` ${String(percent).padStart(3)}% (${input.done}/${input.total} tasks)`;
259
+ const row =
260
+ paint(BAR.filled.repeat(filled), "accent", theme, true) +
261
+ paint(BAR.empty.repeat(BAR.cells - filled), "dim", theme) +
262
+ paint(padTo(counts, CONTENT - BAR.cells), "dim", theme);
263
+ return boxRow(row, theme);
264
+ }
265
+
266
+ function boxRow(content: string, theme?: PanelTheme): string {
267
+ return paint("│", "muted", theme) + " " + content + " " + paint("│", "muted", theme);
268
+ }
269
+
270
+ function bodyLines(
271
+ input: LargeSceneInput,
272
+ width: number,
273
+ theme: PanelTheme | undefined,
274
+ avail: number,
275
+ ): string[] {
276
+ const sections = input.tasks.length > 0;
277
+ const plan = pickPlan(avail, sections);
278
+ const entries = plan.section ? clamp(avail - blockRows(plan) - 1, 0, MAX_TASK_ROWS) : 0;
279
+ return [
280
+ ...towerLines(input, width, theme, plan.tower),
281
+ ...(plan.branch ? branchLines(input, width, theme) : []),
282
+ ...(plan.strip ? slotLines(input, width, theme) : []),
283
+ ...sectionLines(input, width, theme, entries),
284
+ ];
285
+ }
286
+
287
+ function blockRows(plan: LayoutPlan): number {
288
+ const tower = TOWER_ROWS[plan.tower];
289
+ return tower + (plan.branch ? BRANCH_ROWS : 0) + (plan.strip ? SLOT_ROWS : 0);
290
+ }
291
+
292
+ function pickPlan(avail: number, sections: boolean): LayoutPlan {
293
+ const needed = (plan: LayoutPlan) => blockRows(plan) + (plan.section && sections ? 1 + plan.minEntries : 0);
294
+ return PLANS.find((plan) => needed(plan) <= avail) ?? EMPTY_PLAN;
295
+ }
296
+
297
+ function oracleFrame(input: LargeSceneInput): OracleFrame {
298
+ const frames = ORACLE_FRAMES[input.oracle.pose];
299
+ return frames[mod(input.oracle.frame, frames.length)]!;
300
+ }
301
+
302
+ function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
303
+ const frame = oracleFrame(input);
304
+ const values = new Map<string, string>([
305
+ [TOWER.tokens.orb, frame.orb],
306
+ [TOWER.tokens.winL, frame.winL],
307
+ [TOWER.tokens.winR, frame.winR],
308
+ [TOWER.tokens.mouth, frame.mouth],
309
+ [TOWER.tokens.door, input.doorLabel ?? TOWER_DOOR],
310
+ ]);
311
+ const accent = ORACLE_COLORS[input.oracle.pose];
312
+ const parts = row.split(TOWER_PATTERN);
313
+ return parts
314
+ .map((part) => {
315
+ const value = values.get(part);
316
+ return value === undefined ? paint(part, "muted", theme) : paint(value, accent.color, theme, accent.bold);
317
+ })
318
+ .join("");
319
+ }
320
+
321
+ /** The oracle spinner line: the master's live tool activity, or the dormant word. */
322
+ function oracleLine(input: LargeSceneInput, width: number, theme?: PanelTheme): string {
323
+ const accent = ORACLE_COLORS[input.oracle.pose];
324
+ return place(paint(oracleStatusText(input), accent.color, theme, accent.bold), width);
325
+ }
326
+
327
+ function oracleStatusText(input: LargeSceneInput): string {
328
+ if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
329
+ return `${SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!} ${input.oracleActivity ?? "working"}`;
330
+ }
331
+
332
+ function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
333
+ if (size === "none") return [];
334
+ const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
335
+ return [oracleLine(input, width, theme), ...rows.map((row) => place(towerRow(row, input, theme), width))];
336
+ }
337
+
338
+ function stripWidth(count: number): number {
339
+ return count * SLOT_CELL + Math.max(0, count - 1) * SLOT_GAP;
340
+ }
341
+
342
+ function stripLeft(count: number, width: number): number {
343
+ return Math.max(0, Math.floor((width - stripWidth(count)) / 2));
344
+ }
345
+
346
+ function slotOffsets(count: number, width: number): number[] {
347
+ const left = stripLeft(count, width);
348
+ return Array.from({ length: count }, (_value, index) => left + index * (SLOT_CELL + SLOT_GAP));
349
+ }
350
+
351
+ function branchLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
352
+ const count = Math.min(input.slots.length, MAX_SLOTS);
353
+ if (count === 0) return [];
354
+ const nodes = slotOffsets(count, width).map((offset) => offset + Math.floor(SLOT_CELL / 2));
355
+ const stem = sceneCentre(width);
356
+ const end = nodes[nodes.length - 1]! + 1;
357
+ const rows = [stemLine(stem, end), treeLine(nodes, stem, end), pipeLine(nodes, end)];
358
+ return rows.map((row) => paint(row, "muted", theme));
359
+ }
360
+
361
+ /** Absolute-coordinate row: an explicit mark wins, otherwise `fill` draws the cell. */
362
+ function absRow(length: number, fill: (index: number) => string, marks: ReadonlyMap<number, string>): string {
363
+ return Array.from({ length }, (_value, index) => marks.get(index) ?? fill(index)).join("");
364
+ }
365
+
366
+ /** The tower stem, dropping from `sceneCentre(width)` onto the tree row. */
367
+ function stemLine(stem: number, end: number): string {
368
+ return absRow(Math.max(end, stem + 1), () => " ", new Map([[stem, "│"]]));
369
+ }
370
+
371
+ function treeLine(nodes: readonly number[], stem: number, end: number): string {
372
+ const marks = new Map<number, string>();
373
+ nodes.forEach((node, index) => marks.set(node, index === 0 ? "┌" : index === nodes.length - 1 ? "┐" : "┬"));
374
+ if (!marks.has(stem) && stem > nodes[0]! && stem < nodes[nodes.length - 1]!) marks.set(stem, "┴");
375
+ return absRow(end, (index) => (index >= nodes[0]! ? "─" : " "), marks);
376
+ }
377
+
378
+ function pipeLine(nodes: readonly number[], end: number): string {
379
+ return absRow(end, () => " ", new Map(nodes.map((node) => [node, "│"] as const)));
380
+ }
381
+
382
+ function slotLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
383
+ const slots = input.slots.slice(0, MAX_SLOTS);
384
+ if (slots.length === 0) return [];
385
+ const offsets = slotOffsets(slots.length, width);
386
+ const end = stripLeft(slots.length, width) + stripWidth(slots.length);
387
+ return [
388
+ assemble(end, faceCells(slots, offsets, theme)),
389
+ assemble(end, labelCells(slots, offsets, theme)),
390
+ assemble(end, wordCells(slots, offsets, input.tick, theme)),
391
+ assemble(end, elapsedCells(slots, offsets, theme)),
392
+ ];
393
+ }
394
+
395
+ function slotFrame(slot: LargeSlot): readonly string[] {
396
+ const frames = SLOT_FRAMES[slot.id][slot.status];
397
+ return frames[mod(slot.frame, frames.length)] ?? [];
398
+ }
399
+
400
+ function faceCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
401
+ return slots.map((slot, index) => ({
402
+ offset: offsets[index]!,
403
+ text: centre(slotFrame(slot)[0] ?? "", SLOT_CELL),
404
+ paint: statusPaint(slot.status, theme),
405
+ }));
406
+ }
407
+
408
+ function labelCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
409
+ return slots.map((slot, index) => ({
410
+ offset: offsets[index]!,
411
+ text: centre(slot.label || SLOT_LABELS[slot.id], SLOT_CELL),
412
+ paint: labelPaint(slot.status, theme),
413
+ }));
414
+ }
415
+
416
+ function wordCells(slots: readonly LargeSlot[], offsets: readonly number[], tick: number, theme?: PanelTheme): Cell[] {
417
+ return slots.map((slot, index) => ({
418
+ offset: offsets[index]!,
419
+ text: centre(slotStatusText(slot, tick), SLOT_CELL),
420
+ paint: statusPaint(slot.status, theme),
421
+ }));
422
+ }
423
+
424
+ function elapsedCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
425
+ return slots.map((slot, index) => ({
426
+ offset: offsets[index]!,
427
+ text: centre(slot.elapsedLabel, SLOT_CELL),
428
+ paint: dimPaint(theme),
429
+ }));
430
+ }
431
+
432
+ /** Working agents show the braille spinner with their live activity; every other state keeps its glyph and word. */
433
+ function slotStatusText(slot: LargeSlot, tick: number): string {
434
+ if (slot.status !== "working") return `${SLOT_STATE_GLYPHS[slot.status]} ${SLOT_STATE_WORDS[slot.status]}`;
435
+ return `${SPIN_FRAMES[mod(tick, SPIN_FRAMES.length)]!} ${slot.activity ?? SLOT_STATE_WORDS.working}`;
436
+ }
437
+
438
+ /** The elapsed row stays dim so the status row keeps the colour. */
439
+ function dimPaint(theme?: PanelTheme): ((text: string) => string) | undefined {
440
+ return theme ? (text) => theme.fg("dim", text) : undefined;
441
+ }
442
+
443
+ function assemble(end: number, cells: readonly Cell[]): string {
444
+ let row = "";
445
+ let cursor = 0;
446
+ for (const cell of cells) {
447
+ row += " ".repeat(Math.max(0, cell.offset - cursor)) + (cell.paint ? cell.paint(cell.text) : cell.text);
448
+ cursor = cell.offset + visibleWidth(cell.text);
449
+ }
450
+ return row + " ".repeat(Math.max(0, end - cursor));
451
+ }
452
+
453
+ /** The full-width TASKS checklist whenever it has rows and the entry budget allows it. */
454
+ function sectionLines(
455
+ input: LargeSceneInput,
456
+ width: number,
457
+ theme: PanelTheme | undefined,
458
+ entries: number,
459
+ ): string[] {
460
+ if (entries < 1) return [];
461
+ const tasks = input.tasks.slice(0, MAX_TASK_ROWS);
462
+ if (tasks.length === 0) return [];
463
+ return soloSection(" TASKS", tasks.map((task) => taskCell(task, TASKS_CELL, theme)), entries, width, theme);
464
+ }
465
+
466
+ function soloSection(
467
+ header: string,
468
+ cells: readonly string[],
469
+ entries: number,
470
+ width: number,
471
+ theme?: PanelTheme,
472
+ ): string[] {
473
+ const left = sceneLeft(width);
474
+ const rows = [paint(header, "muted", theme, true), ...cells.slice(0, entries)];
475
+ return rows.map((row) => " ".repeat(left) + row);
476
+ }
477
+
478
+ function taskCell(task: LargeTaskRow | undefined, cell: number, theme?: PanelTheme): string {
479
+ if (!task) return " ".repeat(cell);
480
+ const icon = task.status === "done" ? "[x]" : task.status === "current" ? "[>]" : "[ ]";
481
+ const text = padTo(`${icon} ${task.text}`, cell);
482
+ return paint(text, TASK_COLORS[task.status], theme, task.status === "current");
483
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Derived zen-scene metrics: plan progress and per-slot status, live activity
3
+ * word and per-agent elapsed.
4
+ * Pure by construction: the only clock input is the `now` argument, and nothing
5
+ * here reads the wall clock, a random source or the environment. Percentages
6
+ * are plan-derived measurements.
7
+ */
8
+ import type { AgentRun } from "../schemas/findings.ts";
9
+ import type { Task } from "../schemas/task.ts";
10
+ import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art.ts";
11
+ import { formatDuration, planChecklist } from "./zen.ts";
12
+
13
+ export type { SlotId, SlotState };
14
+
15
+ /** One agent column: its latest run's status, live activity word and elapsed label. */
16
+ export interface SlotView {
17
+ id: SlotId;
18
+ label: string;
19
+ status: SlotState;
20
+ /** The run's one-word tool activity, when the backend reported one. */
21
+ activity?: string;
22
+ /** "—" without a run, live since `startedAt` while running, fixed once terminal. */
23
+ elapsedLabel: string;
24
+ }
25
+
26
+ export interface SceneMetrics {
27
+ done: number;
28
+ total: number;
29
+ elapsedLabel: string;
30
+ slots: SlotView[];
31
+ }
32
+
33
+ /** Latest run state per slot: running -> working, success -> done, everything else -> failed. */
34
+ export function runStatus(status: AgentRun["status"]): SlotState {
35
+ if (status === "running") return "working";
36
+ return status === "success" ? "done" : "failed";
37
+ }
38
+
39
+ /** The column a run belongs to; a researcher reports under RESEARCH from any domain. */
40
+ function slotOf(run: AgentRun): SlotId {
41
+ if (run.role === "researcher") return "research";
42
+ if (run.domain === "backend") return "dev";
43
+ return run.domain === "designer" ? "design" : "qa";
44
+ }
45
+
46
+ function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
47
+ let latest: AgentRun | undefined;
48
+ for (const run of runs) {
49
+ if (slotOf(run) !== id) continue;
50
+ if (!latest || Date.parse(run.startedAt) >= Date.parse(latest.startedAt)) latest = run;
51
+ }
52
+ return latest;
53
+ }
54
+
55
+ /** A run's duration label from the injected clock; a malformed timestamp reads "0s". */
56
+ function runElapsedLabel(run: AgentRun | undefined, now: number): string {
57
+ if (!run) return "—";
58
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
59
+ return formatDuration(end - Date.parse(run.startedAt));
60
+ }
61
+
62
+ function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
63
+ const run = latestRunFor(runs, id);
64
+ const status: SlotState = run ? runStatus(run.status) : "idle";
65
+ return { id, label: SLOT_LABELS[id], status, activity: run?.activity, elapsedLabel: runElapsedLabel(run, now) };
66
+ }
67
+
68
+ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
69
+ const checklist = planChecklist(task.plan ?? "", runs);
70
+ const total = checklist.length;
71
+ const done = checklist.filter((step) => step.status === "done").length;
72
+ const summary = { done, total };
73
+ const created = Date.parse(task.createdAt);
74
+ const elapsed = Number.isFinite(created) ? Math.max(0, now - created) : 0;
75
+ return {
76
+ ...summary,
77
+ elapsedLabel: formatDuration(elapsed),
78
+ slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
79
+ };
80
+ }