huddle-cli 0.0.0-stage → 0.1.1

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 (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,1529 @@
1
+ // `huddle story` — one place to SEE a run's collaboration, after the fact.
2
+ //
3
+ // Everything comes from the Room db's event table (.huddle/huddle.db): the plan
4
+ // (task.added + plan.made), the scheduler's own events (persisted as run.*),
5
+ // agents' messages, questions and answers, announced plans, shared memory,
6
+ // reviews, merges — and whatever newer phases write (council.*, takeover,
7
+ // split, qa.*). Known types get a sentence; any other type is still shown,
8
+ // generically, with its fields, so nothing a run recorded is ever lost.
9
+ //
10
+ // huddle story the latest run, as a timeline + who-did-what
11
+ // huddle story --list every run in this repo's room
12
+ // huddle story --run <n|id> one run (number from --list, or session id)
13
+ // huddle story --html out.html a self-contained static report (lanes, threads)
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { C, dot, pad, truncate, AGENT_COLOR } from "./term.js";
17
+ import { fmtDuration, fmtTokens } from "../adapters/usage.js";
18
+ import { isHelpOffer } from "../room/progress.js";
19
+ /** The scheduler's own event types, persisted as "run.<type>" (scheduler.ts). */
20
+ const CORE_RUN = new Set(["start", "declined", "tokens", "review", "reviewed", "merged", "failed", "stuck", "conflict", "resolved", "handoff"]);
21
+ const NOT_AGENTS = new Set(["huddle", "room", "all", "you", "council", ""]);
22
+ function parse(e) {
23
+ let p = {};
24
+ try {
25
+ const v = JSON.parse(e.payload || "{}");
26
+ if (v && typeof v === "object" && !Array.isArray(v))
27
+ p = v;
28
+ else
29
+ p = { value: v };
30
+ }
31
+ catch {
32
+ p = { payload: e.payload };
33
+ }
34
+ let type = e.type;
35
+ if (type.startsWith("run.") && !CORE_RUN.has(type.slice(4)))
36
+ type = type.slice(4);
37
+ if (type !== e.type && p.type === e.type.slice(4))
38
+ delete p.type;
39
+ if (e.type.startsWith("run."))
40
+ delete p.type;
41
+ return { ts: e.ts, type, agent: e.agent_id, task: e.task_id, p };
42
+ }
43
+ const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
44
+ /** First non-empty field among `keys`. */
45
+ const pick = (p, ...keys) => {
46
+ for (const k of keys) {
47
+ const v = p[k];
48
+ if (v !== undefined && v !== null && str(v).trim())
49
+ return str(v);
50
+ }
51
+ return "";
52
+ };
53
+ const oneLine = (s, n = 140) => {
54
+ const l = s.replace(/\s+/g, " ").trim();
55
+ return l.length <= n ? l : l.slice(0, n - 1) + "…";
56
+ };
57
+ /**
58
+ * A goal as a headline. Found on the capstone: a 3.5 KB project brief was the
59
+ * STORY line (and the list row, and the page's <h1>), escaped quotes and all.
60
+ * Its first line, capped; the full brief stays in the HTML under "The goal".
61
+ */
62
+ export const goalTitle = (goal, n = 160) => oneLine(goal.split(/\n/)[0] ?? "", n);
63
+ /**
64
+ * "(didn't write it)" only when that's true. Found live (capstone round 3):
65
+ * every agent had written code, QA said "checking anyway", and the story still
66
+ * claimed fresh eyes. Older runs (no `authors` on qa.round) keep the old line.
67
+ */
68
+ function qaEyes(checkers, authors) {
69
+ if (authors === undefined)
70
+ return "(didn't write it)";
71
+ const wrote = new Set(list(authors));
72
+ const mine = checkers.filter((a) => wrote.has(a));
73
+ if (!mine.length)
74
+ return "(didn't write it)";
75
+ return mine.length === checkers.length ? "(wrote some of it too — checking anyway)" : `(${mine.join(", ")} wrote some of it too)`;
76
+ }
77
+ const list = (v) => {
78
+ if (Array.isArray(v))
79
+ return v.map(str);
80
+ if (typeof v === "string") {
81
+ try {
82
+ const j = JSON.parse(v);
83
+ if (Array.isArray(j))
84
+ return j.map(str);
85
+ }
86
+ catch {
87
+ /* plain string */
88
+ }
89
+ return v ? [v] : [];
90
+ }
91
+ return [];
92
+ };
93
+ /**
94
+ * Cut the event log into runs. A run starts at the board.cleared just before
95
+ * its session.created (one goal = one board); council.* events written before
96
+ * that board was cleared belong to the run they designed. A room with no goals
97
+ * (attach mode) is one run.
98
+ */
99
+ export function splitRuns(raw) {
100
+ const evs = raw.map(parse);
101
+ const anchors = evs.map((e, i) => (e.type === "session.created" ? i : -1)).filter((i) => i >= 0);
102
+ const mk = (index, events, s) => ({
103
+ index,
104
+ id: s ? str(s.p.id) || null : null,
105
+ goal: s ? str(s.p.goal) : "(no goal — attach sessions)",
106
+ start: events[0]?.ts ?? 0,
107
+ end: events.at(-1)?.ts ?? 0,
108
+ events,
109
+ });
110
+ if (!anchors.length)
111
+ return evs.length ? [mk(1, evs)] : [];
112
+ const starts = anchors.map((a, k) => {
113
+ const floor = k ? anchors[k - 1] + 1 : 0;
114
+ for (let i = a; i >= floor; i--)
115
+ if (evs[i].type === "board.cleared")
116
+ return i;
117
+ return a;
118
+ });
119
+ const chunks = starts.map((s, k) => evs.slice(s, k + 1 < starts.length ? starts[k + 1] : evs.length));
120
+ // The design talk happens before the board is cleared: move it forward.
121
+ // So is what the user put on the shared screen just before typing the goal.
122
+ const isDesign = (e) => e.type.startsWith("council.") || e.type === "context.added";
123
+ const before = evs.slice(0, starts[0]).filter(isDesign);
124
+ chunks[0] = [...before, ...chunks[0]];
125
+ for (let k = 1; k < chunks.length; k++) {
126
+ const prev = chunks[k - 1];
127
+ let cut = prev.length;
128
+ while (cut > 0 && isDesign(prev[cut - 1]))
129
+ cut--;
130
+ if (cut < prev.length) {
131
+ chunks[k] = [...prev.slice(cut), ...chunks[k]];
132
+ chunks[k - 1] = prev.slice(0, cut);
133
+ }
134
+ }
135
+ return chunks.map((c, k) => mk(k + 1, c, evs[anchors[k]]));
136
+ }
137
+ /** Pick a run: latest by default, else its number (from --list) or session id. */
138
+ export function pickRun(runs, which) {
139
+ if (!which)
140
+ return runs.at(-1);
141
+ const n = Number(which);
142
+ if (Number.isInteger(n))
143
+ return n > 0 ? runs.find((r) => r.index === n) : runs.at(n - 1); // 0 = latest, -1 = the one before
144
+ return runs.find((r) => r.id === which);
145
+ }
146
+ const newStats = () => ({
147
+ merged: 0,
148
+ failed: 0,
149
+ reviews: 0,
150
+ changesAsked: 0,
151
+ sent: 0,
152
+ received: 0,
153
+ proposals: 0,
154
+ critiques: 0,
155
+ bugsFound: 0,
156
+ bugsFixed: 0,
157
+ takeoversIn: 0,
158
+ takeoversOut: 0,
159
+ toolCalls: 0,
160
+ ms: 0,
161
+ tokens: 0,
162
+ cached: 0,
163
+ });
164
+ export function buildStory(run, runs = 1) {
165
+ const titles = new Map();
166
+ const owner = new Map(); // task -> agent on it now
167
+ const agents = [];
168
+ const stats = new Map();
169
+ const talk = new Map();
170
+ const threads = new Map();
171
+ const items = [];
172
+ const design = [];
173
+ const spans = [];
174
+ const plan = { tasks: [] };
175
+ const open = new Map(); // agent -> its open span
176
+ const councilTasks = [];
177
+ const collab = { adopts: [], flaws: [], ratify: [], objections: [], amendments: [], radar: [], buddy: [], followups: [], bugs: [], pairs: [], credit: new Map() };
178
+ const mergedTitles = [];
179
+ const screen = new Map();
180
+ const onScreen = (p) => {
181
+ const id = pick(p, "id");
182
+ if (!screen.has(id))
183
+ screen.set(id, { id, kind: pick(p, "kind"), title: pick(p, "title"), by: "", size: "", readers: [], notes: [] });
184
+ const e = screen.get(id);
185
+ if (!e.title)
186
+ e.title = pick(p, "title");
187
+ if (!e.kind)
188
+ e.kind = pick(p, "kind");
189
+ return e;
190
+ };
191
+ let merged = 0;
192
+ let failed = 0;
193
+ // The brain that settles a council plan (a label like "claude · opus") is not an agent on the crew.
194
+ const working = new Set(run.events.filter((e) => e.type === "run.start" || e.type === "agent.joined").map((e) => e.agent));
195
+ const brains = new Set(run.events.filter((e) => e.type === "council.plan").map((e) => pick(e.p, "by")).filter((b) => b && !working.has(b)));
196
+ const isAgent = (a) => !!a && !NOT_AGENTS.has(a) && !brains.has(a);
197
+ const see = (a) => {
198
+ if (!isAgent(a))
199
+ return;
200
+ if (!agents.includes(a))
201
+ agents.push(a);
202
+ if (!stats.has(a))
203
+ stats.set(a, newStats());
204
+ };
205
+ const st = (a) => {
206
+ see(a);
207
+ return (isAgent(a) && stats.get(a)) || newStats();
208
+ };
209
+ const talked = (from, to) => {
210
+ if (!isAgent(from) || !isAgent(to) || from === to)
211
+ return;
212
+ see(from);
213
+ see(to);
214
+ const row = talk.get(from) ?? new Map();
215
+ row.set(to, (row.get(to) ?? 0) + 1);
216
+ talk.set(from, row);
217
+ };
218
+ const thread = (it) => {
219
+ if (!it.agent || !it.to)
220
+ return;
221
+ const key = [it.agent, it.to].sort().join(" ↔ ");
222
+ threads.set(key, [...(threads.get(key) ?? []), it]);
223
+ };
224
+ const title = (task, p) => pick(p ?? {}, "title") || (task ? titles.get(task) ?? task : "");
225
+ const q = (s) => (s ? `“${s}”` : "");
226
+ const close = (agent, ts, outcome) => {
227
+ const s = open.get(agent);
228
+ if (!s)
229
+ return;
230
+ s.to = Math.max(ts, s.from);
231
+ if (outcome)
232
+ s.outcome = outcome;
233
+ open.delete(agent);
234
+ };
235
+ const begin = (s) => {
236
+ close(s.agent, s.from);
237
+ const span = { ...s, to: run.end };
238
+ spans.push(span);
239
+ open.set(s.agent, span);
240
+ };
241
+ const add = (it) => {
242
+ see(it.agent);
243
+ (it.kind === "design" ? design : items).push(it);
244
+ };
245
+ const hasRun = run.events.some((e) => e.type.startsWith("run."));
246
+ // The scheduler's "takeover" and the Room's "task.takeover" describe the same handover;
247
+ // when the Room's is there it tells the story and the scheduler's only carries the cost.
248
+ const roomTakeovers = run.events.some((e) => e.type === "task.takeover");
249
+ const firstWork = run.events.find((e) => e.type === "run.start" || e.type === "task.claimed")?.ts ?? Infinity;
250
+ const recent = [];
251
+ for (const e of run.events) {
252
+ const p = e.p;
253
+ const ts = e.ts;
254
+ // The same phase may be written twice (a Room event and a scheduler event): show it once.
255
+ if (/^(council\.|takeover|split|task\.takeover|task\.split|qa\.)/.test(e.type)) {
256
+ // round + agents: two QA rounds a second apart are two rounds (mock capstone: round 2's line vanished).
257
+ const sig = [e.type, e.task, pick(p, "from", "agent", "by") || e.agent, pick(p, "to"), oneLine(pick(p, "title", "summary", "body", "reason"), 60), p.round === undefined ? "" : str(p.round), list(p.agents).join(",")].join("|");
258
+ if (recent.some((r) => r.sig === sig && ts - r.ts < 2000))
259
+ continue;
260
+ recent.push({ sig, ts });
261
+ }
262
+ switch (e.type) {
263
+ case "session.created":
264
+ case "board.cleared":
265
+ case "agent.status":
266
+ case "handoff.ready":
267
+ case "run.tokens":
268
+ case "run.handoff": // the Room's own "handoff" event says the same, with the files
269
+ break;
270
+ case "agent.joined":
271
+ see(e.agent ?? pick(p, "id"));
272
+ break;
273
+ case "plug.call":
274
+ st(e.agent).toolCalls++;
275
+ break;
276
+ case "context.added": {
277
+ const x = onScreen(p);
278
+ x.by = pick(p, "by") || "you";
279
+ x.size = Number(p.lines) ? `${p.lines} lines` : Number(p.bytes) ? `${Math.max(1, Math.round(Number(p.bytes) / 1024))} KB` : "";
280
+ add({ ts, kind: "team", type: e.type, agent: isAgent(x.by) ? x.by : null, text: `📎 ${isAgent(x.by) ? "" : x.by + " "}put ${x.id} ${q(x.title)} on the shared screen${x.size ? " (" + x.size + ")" : ""}` });
281
+ break;
282
+ }
283
+ case "context.read": {
284
+ const x = onScreen(p);
285
+ const who = e.agent ?? pick(p, "by");
286
+ if (who && !x.readers.includes(who)) {
287
+ x.readers.push(who);
288
+ // First read of each item by each agent: the proof that they looked at the screen.
289
+ add({ ts, kind: "progress", type: e.type, agent: who, text: `📖 read ${x.id} ${q(x.title)}${p.from ? ` (lines ${p.from}-${p.to})` : ""}` });
290
+ }
291
+ break;
292
+ }
293
+ case "context.note": {
294
+ const x = onScreen(p);
295
+ const who = pick(p, "by") || e.agent || "you";
296
+ x.notes.push({ by: who, body: pick(p, "body") });
297
+ add({ ts, kind: "message", type: e.type, agent: isAgent(who) ? who : null, text: `📌 on ${x.id} ${q(x.title)}: ${oneLine(pick(p, "body"), 300)}${isAgent(who) ? "" : ` (${who})`}` });
298
+ break;
299
+ }
300
+ case "context.removed":
301
+ onScreen(p).removed = true;
302
+ break;
303
+ case "context.search":
304
+ break;
305
+ case "plan.made":
306
+ plan.by = pick(p, "by") || plan.by;
307
+ plan.note = pick(p, "note") || plan.note;
308
+ break;
309
+ case "task.added": {
310
+ const id = pick(p, "id") || e.task || "";
311
+ const t = pick(p, "title");
312
+ titles.set(id, t);
313
+ if (ts <= firstWork) {
314
+ plan.tasks.push({
315
+ id,
316
+ title: t,
317
+ deps: list(p.depends_on),
318
+ prefers: list(p.prefers),
319
+ files: list(p.files),
320
+ by: pick(p, "proposed_by", "by", "author") || undefined,
321
+ });
322
+ }
323
+ else {
324
+ const by = pick(p, "proposed_by", "by", "author", "filed_by");
325
+ add({ ts, kind: "task", type: e.type, agent: isAgent(by) ? by : null, text: `new task on the board: ${q(t)}`, detail: oneLine(pick(p, "spec"), 200) });
326
+ }
327
+ break;
328
+ }
329
+ case "task.claimed": {
330
+ if (e.task && e.agent)
331
+ owner.set(e.task, e.agent);
332
+ see(e.agent);
333
+ const viaScheduler = run.events.some((x) => x.type === "run.start" && x.task === e.task && x.agent === e.agent && Math.abs(x.ts - ts) < 3000);
334
+ if (!viaScheduler)
335
+ add({ ts, kind: "task", type: e.type, agent: e.agent, text: `claims ${q(title(e.task))}` });
336
+ break;
337
+ }
338
+ case "task.progress": {
339
+ const note = pick(p, "note");
340
+ if (!note)
341
+ break;
342
+ add({ ts, kind: "progress", type: e.type, agent: (e.task && owner.get(e.task)) || null, text: `${String(p.pct ?? "?").padStart(3)}% ${oneLine(note)}`, detail: q(title(e.task)) });
343
+ break;
344
+ }
345
+ case "task.status": {
346
+ if (hasRun)
347
+ break; // the scheduler's run.* events tell it better
348
+ const s = pick(p, "status");
349
+ if (["merged", "failed", "needs_you"].includes(s)) {
350
+ const who = (e.task && owner.get(e.task)) || null;
351
+ if (s === "merged")
352
+ merged++;
353
+ else
354
+ failed++;
355
+ add({ ts, kind: s === "merged" ? "merge" : "fail", type: e.type, agent: who, text: `${s === "merged" ? "✓ merged" : s === "failed" ? "✗ failed" : "! needs you"} ${q(title(e.task))}` });
356
+ }
357
+ break;
358
+ }
359
+ case "plan.announced": {
360
+ const touches = list(p.touches);
361
+ const hits = Array.isArray(p.conflicts) ? p.conflicts : [];
362
+ add({
363
+ ts,
364
+ kind: "announce",
365
+ type: e.type,
366
+ agent: e.agent,
367
+ text: `announces: ${oneLine(pick(p, "intent"), 110)}${touches.length ? ` · touches ${touches.slice(0, 5).join(", ")}${touches.length > 5 ? "…" : ""}` : ""}`,
368
+ detail: hits.length ? `overlaps: ${hits.map((h) => `${str(h.path)} (held by ${str(h.held_by)})`).join(", ")}` : undefined,
369
+ });
370
+ see(e.agent);
371
+ break;
372
+ }
373
+ case "memory.added": {
374
+ const author = pick(p, "author") || e.agent || "";
375
+ const body = pick(p, "body");
376
+ if (author === "huddle") {
377
+ // Mostly the scheduler restating what run.* says; the red gate is news.
378
+ if (/red test gate/.test(body))
379
+ add({ ts, kind: "fail", type: e.type, agent: null, text: oneLine(body) });
380
+ break;
381
+ }
382
+ if (/^Review of /.test(body))
383
+ break; // the verdict is the review item
384
+ const kind = pick(p, "kind") || "note";
385
+ const done = /^Task \S+ done:\s*/.exec(body);
386
+ add({
387
+ ts,
388
+ kind: done ? "task" : "memory",
389
+ type: e.type,
390
+ agent: author,
391
+ text: done ? `done: ${oneLine(body.slice(done[0].length))}` : `${kind === "decision" && /contract/i.test(body) ? "contract" : kind}: ${oneLine(body)}`,
392
+ detail: pick(p, "why") ? `why: ${oneLine(pick(p, "why"), 200)}` : undefined,
393
+ });
394
+ see(author);
395
+ break;
396
+ }
397
+ case "agent.message":
398
+ case "note": {
399
+ const from = pick(p, "from") || e.agent || "?";
400
+ const to = pick(p, "to") || "?";
401
+ const kind = pick(p, "kind") || "note";
402
+ // Huddle's automatic help offer, sent in a free agent's name: a board event,
403
+ // not a message the agent wrote. Found live (capstone round 2): 6 offers
404
+ // and no real message, yet the table said claude sent 3 messages.
405
+ // The radar's mail: the run.radar item already tells it, once per finding.
406
+ if (from === "huddle" && /^Huddle radar:/.test(pick(p, "body")))
407
+ break;
408
+ // Buddy notes and contract broadcasts: run.checkpoint / contract.amended tell them once.
409
+ if (from === "huddle" && /^buddy check from /.test(pick(p, "body")))
410
+ break;
411
+ if (/^contract v\d+ by /.test(pick(p, "body")))
412
+ break;
413
+ if (from === "huddle" && / is taking “.*” \(files .*\) in \d+s unless you reply/.test(pick(p, "body"))) {
414
+ add({ ts, kind: "takeover", type: "pair.proposed", agent: null, to, text: `proposes: ${oneLine(pick(p, "body").split(" unless you reply")[0], 200)} (${to} may say no)` });
415
+ break;
416
+ }
417
+ if (isHelpOffer(from, pick(p, "body"))) {
418
+ const t = /your task "(.*)" \(/.exec(pick(p, "body"))?.[1];
419
+ add({ ts, kind: "task", type: "offer", agent: from, to, text: `is free: Huddle offers ${to} help${t ? ` with ${q(t)}` : ""} (huddle_split to hand off a part)` });
420
+ break;
421
+ }
422
+ const it = { ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "body"), 400), detail: kind === "note" ? undefined : kind };
423
+ add(it);
424
+ thread(it);
425
+ st(from).sent++;
426
+ st(to).received++;
427
+ talked(from, to);
428
+ break;
429
+ }
430
+ case "ask": {
431
+ if (p.routed)
432
+ break; // it's also an agent.message (kind question)
433
+ const from = pick(p, "from") || e.agent || "?";
434
+ const to = pick(p, "to") || "room";
435
+ const it = { ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "question"), 400), detail: "question" };
436
+ add(it);
437
+ st(from).sent++;
438
+ if (isAgent(to)) {
439
+ st(to).received++;
440
+ talked(from, to);
441
+ thread(it);
442
+ }
443
+ break;
444
+ }
445
+ case "answer": {
446
+ if (pick(p, "source") === "teammate")
447
+ break; // it's also an agent.message (kind answer)
448
+ const from = pick(p, "from") || "room";
449
+ const to = pick(p, "to");
450
+ add({ ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "answer"), 400), detail: `answer · ${pick(p, "source") || "shared memory"}` });
451
+ if (isAgent(to))
452
+ st(to).received++;
453
+ break;
454
+ }
455
+ case "handoff": {
456
+ const from = pick(p, "from") || e.agent;
457
+ const to = pick(p, "to");
458
+ const files = list(p.files);
459
+ add({ ts, kind: "handoff", type: e.type, agent: from, to: to && to !== from ? to : null, text: `hands off ${q(pick(p, "title"))} · ${files.length} file(s), ${list(p.symbols).length} export(s)`, detail: files.slice(0, 6).join(", ") || undefined });
460
+ talked(from, to);
461
+ break;
462
+ }
463
+ case "message":
464
+ add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "from")} (person): ${oneLine(pick(p, "body"), 300)}` });
465
+ break;
466
+ case "member.joined":
467
+ add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "github") ? "@" + pick(p, "github") : pick(p, "name")} joined the room as ${pick(p, "role") || "member"}` });
468
+ break;
469
+ case "member.left":
470
+ add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "name")} ${pick(p, "reason") || "left"}` });
471
+ break;
472
+ case "crew.updated":
473
+ add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "member")}'s crew: ${list(p.agents).join(", ") || "(none)"}` });
474
+ break;
475
+ case "assign":
476
+ add({ ts, kind: "task", type: e.type, agent: e.agent, text: `offered ${q(pick(p.task ?? {}, "title") || title(e.task))} on ${pick(p, "worker") || "a teammate"}'s machine` });
477
+ break;
478
+ case "live.joined":
479
+ see(e.agent);
480
+ add({ ts, kind: "team", type: e.type, agent: e.agent, text: `joined live${pick(p, "branch") ? " on " + pick(p, "branch") : ""}` });
481
+ break;
482
+ case "live.left":
483
+ add({ ts, kind: "team", type: e.type, agent: e.agent, text: `left${pick(p, "why") ? " · " + pick(p, "why") : ""}` });
484
+ break;
485
+ case "live.released":
486
+ add({ ts, kind: "announce", type: e.type, agent: e.agent, text: `released ${list(p.paths).join(", ")}` });
487
+ break;
488
+ // ---- the scheduler ----
489
+ case "run.start": {
490
+ const a = pick(p, "agent") || e.agent || "?";
491
+ if (e.task)
492
+ owner.set(e.task, a);
493
+ const t = title(e.task, p);
494
+ begin({ agent: a, from: ts, kind: "task", label: (p.fixing ? "fix: " : "") + t });
495
+ add({ ts, kind: "task", type: e.type, agent: a, text: `${p.fixing ? "▸ fix round on" : "▸ starts"} ${q(t)}${p.remote ? ` · on ${pick(p, "remote")}'s machine` : ""}` });
496
+ break;
497
+ }
498
+ case "run.declined":
499
+ add({ ts, kind: "task", type: e.type, agent: pick(p, "agent") || e.agent, text: `declined ${q(title(e.task, p))} · back on the board` });
500
+ break;
501
+ case "run.review": {
502
+ const a = pick(p, "agent") || e.agent || "?";
503
+ const author = pick(p, "author");
504
+ close(author, ts, undefined);
505
+ begin({ agent: a, from: ts, kind: "review", label: `review ${author}` });
506
+ add({ ts, kind: "review", type: e.type, agent: a, to: author, text: `◎ reviews ${author}'s ${q(title(e.task, p))}` });
507
+ break;
508
+ }
509
+ case "run.reviewed": {
510
+ const a = pick(p, "agent") || e.agent || "?";
511
+ const author = pick(p, "author");
512
+ const s = st(a);
513
+ s.reviews++;
514
+ s.ms += Number(p.ms) || 0;
515
+ if (p.approve === false)
516
+ s.changesAsked++;
517
+ close(a, ts, p.skipped ? "warn" : p.approve === false ? "bad" : "ok");
518
+ const notes = pick(p, "notes");
519
+ const it = {
520
+ ts,
521
+ kind: "review",
522
+ type: e.type,
523
+ agent: a,
524
+ to: author,
525
+ text: p.skipped ? `○ review skipped (${oneLine(notes, 60)})` : p.approve === false ? `✎ asks ${author} for changes` : `✓ approves ${author}'s work`,
526
+ detail: p.approve === false ? notes : undefined,
527
+ };
528
+ add(it);
529
+ if (!p.skipped) {
530
+ talked(a, author);
531
+ thread(it);
532
+ }
533
+ break;
534
+ }
535
+ case "run.merged": {
536
+ const a = pick(p, "agent") || e.agent || "?";
537
+ const s = st(a);
538
+ s.merged++;
539
+ s.ms += Number(p.ms) || 0;
540
+ s.tokens += Number(p.tokens) || 0;
541
+ s.cached += Number(p.cached) || 0;
542
+ merged++;
543
+ if (/^Fix: /.test(title(e.task, p)))
544
+ s.bugsFixed++; // a QA bug's fix task landed
545
+ mergedTitles.push({ title: title(e.task, p), agent: a });
546
+ close(a, ts, "ok");
547
+ add({ ts, kind: "merge", type: e.type, agent: a, text: `✓ merged ${q(title(e.task, p))}`, detail: [pick(p, "reason"), p.ms ? fmtDuration(Number(p.ms)) : "", p.tokens ? fmtTokens(Number(p.tokens)) + " tok" : ""].filter(Boolean).join(" · ") });
548
+ break;
549
+ }
550
+ case "run.failed": {
551
+ const a = pick(p, "agent") || e.agent || "?";
552
+ const s = st(a);
553
+ s.failed++;
554
+ s.ms += Number(p.ms) || 0;
555
+ s.tokens += Number(p.tokens) || 0;
556
+ s.cached += Number(p.cached) || 0;
557
+ failed++;
558
+ close(a, ts, "bad");
559
+ add({ ts, kind: "fail", type: e.type, agent: a, text: `${p.needsYou ? "! needs you:" : "✗ failed"} ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 300) });
560
+ break;
561
+ }
562
+ case "run.stuck":
563
+ add({ ts, kind: "fail", type: e.type, agent: null, text: `! blocked by failures: ${list(p.blocked).join(" · ")}` });
564
+ break;
565
+ case "run.conflict": {
566
+ const a = pick(p, "agent") || e.agent || "?";
567
+ const author = pick(p, "author");
568
+ begin({ agent: a, from: ts, kind: "conflict", label: `resolve ${title(e.task, p)}` });
569
+ add({ ts, kind: "conflict", type: e.type, agent: a, to: author !== a ? author : null, text: `⇄ resolves a conflict in ${author === a ? "its own" : author + "'s"} ${q(title(e.task, p))} · ${list(p.files).join(", ")} · try ${str(p.attempt)}` });
570
+ break;
571
+ }
572
+ case "run.resolved": {
573
+ const a = pick(p, "agent") || e.agent || "?";
574
+ st(a).ms += Number(p.ms) || 0;
575
+ close(a, ts, p.ok ? "ok" : "bad");
576
+ add({ ts, kind: "conflict", type: e.type, agent: a, text: `${p.ok ? "✓ resolved" : "✗ resolution rejected"} ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 200) });
577
+ break;
578
+ }
579
+ // ---- newer phases: design council, takeovers, QA ----
580
+ case "council.proposal": {
581
+ const a = pick(p, "agent", "from", "by") || e.agent;
582
+ st(a).proposals++;
583
+ add({ ts, kind: "design", type: e.type, agent: a, text: `proposes: ${oneLine(pick(p, "summary", "title", "proposal", "body", "text", "plan"), 300)}`, detail: oneLine(pick(p, "risks", "details", "why"), 300) || undefined });
584
+ break;
585
+ }
586
+ case "council.critique": {
587
+ const a = pick(p, "agent", "from", "by", "critic") || e.agent;
588
+ const of = pick(p, "of", "target", "to", "author");
589
+ st(a).critiques++;
590
+ // Council v2: the ADOPT / FLAW lines get their own items (council.adopt / .flaw); here, just where it landed.
591
+ const revised = Array.isArray(p.adopts) || Array.isArray(p.flaws) ? /REVISED\W*:?\s*([\s\S]*)$/i.exec(pick(p, "text"))?.[1] : undefined;
592
+ const it = revised
593
+ ? { ts, kind: "design", type: e.type, agent: a, to: null, text: `revised position: ${oneLine(revised, 300)}` }
594
+ : { ts, kind: "design", type: e.type, agent: a, to: of || null, text: `critiques ${of ? of + "'s proposal" : "the proposals"}: ${oneLine(pick(p, "body", "critique", "text", "summary", "flaws"), 300)}` };
595
+ add(it);
596
+ talked(a, of);
597
+ thread(it);
598
+ break;
599
+ }
600
+ case "council.plan": {
601
+ const by = pick(p, "by", "agent", "merged_by") || e.agent;
602
+ plan.by = plan.by ?? (by ? `${by} (council)` : "council");
603
+ const tasks = Array.isArray(p.tasks) ? p.tasks : [];
604
+ councilTasks.push(...tasks);
605
+ const sy = p.synthesizer && typeof p.synthesizer === "object" ? p.synthesizer : null;
606
+ if (sy)
607
+ collab.synthesizer = { id: str(sy.id), kind: str(sy.kind), why: str(sy.why) };
608
+ for (const o of Array.isArray(p.objections) ? p.objections : [])
609
+ collab.objections.push({ by: str(o?.by), objection: str(o?.objection), taken: o?.taken === true });
610
+ add({
611
+ ts,
612
+ kind: "design",
613
+ type: e.type,
614
+ agent: by,
615
+ text: `writes the plan${tasks.length || p.tasks ? `: ${tasks.length || str(p.tasks)} task(s)` : ""}${sy?.why ? ` · ${sy.kind === "agent" ? "chosen" : "brain"}: ${oneLine(str(sy.why), 160)}` : ""}${pick(p, "summary", "note") ? " · " + oneLine(pick(p, "summary", "note"), 200) : ""}`,
616
+ detail: collab.objections.length ? collab.objections.map((o) => `${o.taken ? "✓ took" : "✗ kept as is"} ${o.by}'s objection: ${oneLine(o.objection, 120)}`).join("\n") : undefined,
617
+ });
618
+ break;
619
+ }
620
+ case "task.takeover": {
621
+ const from = pick(p, "from");
622
+ const to = pick(p, "to") || e.agent;
623
+ if (e.task && to)
624
+ owner.set(e.task, to);
625
+ talked(from, to);
626
+ if (pick(p, "kind") === "split") {
627
+ add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `✂ takes part of ${from}'s task: ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 200) || undefined });
628
+ break;
629
+ }
630
+ st(from).takeoversOut++;
631
+ st(to).takeoversIn++;
632
+ add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `↻ takes over ${q(title(e.task, p))} from ${from}, on the same branch`, detail: oneLine(pick(p, "reason"), 200) || undefined });
633
+ break;
634
+ }
635
+ case "task.split":
636
+ add({ ts, kind: "takeover", type: e.type, agent: pick(p, "from") || e.agent, text: `✂ splits ${q(pick(p, "title"))} off ${q(pick(p, "parent_title"))} for a free agent`, detail: list(p.files).join(", ") || undefined });
637
+ break;
638
+ case "offer": // the help offer itself is an agent.message in the busy agent's inbox
639
+ break;
640
+ case "radar.finding": // the Room's copy of run.radar
641
+ break;
642
+ case "radar.stats": {
643
+ const prev = collab.radarLooks ?? { ticks: 0, looks: 0, pairs: 0 };
644
+ collab.radarLooks = { ticks: prev.ticks + (Number(p.ticks) || 0), looks: prev.looks + (Number(p.looks) || 0), pairs: prev.pairs + (Number(p.pairs) || 0) };
645
+ break;
646
+ }
647
+ case "radar": {
648
+ const w = pick(p, "with");
649
+ collab.radar.push({ agent: e.agent ?? pick(p, "agent"), with: w, kind: pick(p, "kind"), detail: pick(p, "detail") });
650
+ add({ ts, kind: "conflict", type: e.type, agent: e.agent, to: w && w !== "contract" ? w : null, text: `⚠ radar: ${pick(p, "kind") === "overlap" ? "overlap" : "name mismatch"}${w ? ` with ${w}` : ""} · told mid-task`, detail: oneLine(pick(p, "detail"), 300) || undefined });
651
+ break;
652
+ }
653
+ case "takeover": {
654
+ if (roomTakeovers) {
655
+ // The first agent's time and tokens on the task it handed over.
656
+ const s = st(pick(p, "from"));
657
+ s.ms += Number(p.ms) || 0;
658
+ s.tokens += Number(p.tokens) || 0;
659
+ s.cached += Number(p.cached) || 0;
660
+ break;
661
+ }
662
+ const from = pick(p, "from", "was", "previous");
663
+ const to = pick(p, "to", "agent", "by") || e.agent;
664
+ st(from).takeoversOut++;
665
+ st(to).takeoversIn++;
666
+ if (e.task && to)
667
+ owner.set(e.task, to);
668
+ add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `↻ takes over ${q(title(e.task, p))}${from ? ` from ${from}` : ""}`, detail: oneLine(pick(p, "reason", "why"), 200) || undefined });
669
+ talked(from, to);
670
+ break;
671
+ }
672
+ case "split": {
673
+ const from = pick(p, "from", "agent", "by") || e.agent;
674
+ const parts = Array.isArray(p.into) ? p.into : Array.isArray(p.parts) ? p.parts : Array.isArray(p.tasks) ? p.tasks : [];
675
+ const names = parts.map((x) => (typeof x === "string" ? x : pick(x ?? {}, "title", "id"))).filter(Boolean);
676
+ const to = list(p.to ?? p.agents);
677
+ add({ ts, kind: "takeover", type: e.type, agent: from, text: `✂ splits ${q(title(e.task, p))} into ${names.length || "?"} part(s)${to.length ? " → " + to.join(", ") : ""}`, detail: names.join(" · ") || oneLine(pick(p, "reason"), 200) || undefined });
678
+ break;
679
+ }
680
+ case "checkpoint": {
681
+ // A buddy check: another agent read the author's work in progress.
682
+ const a = pick(p, "agent") || e.agent;
683
+ const author = pick(p, "author");
684
+ const notes = pick(p, "notes");
685
+ const ok = !notes || /^LOOKS GOOD/i.test(notes.trim());
686
+ const late = !ok && !!p.late;
687
+ collab.buddy.push({ agent: a ?? "", author, pct: Number(p.pct) || 0, notes: ok ? "" : notes, ...(late ? { late } : {}) });
688
+ const it = { ts, kind: "review", type: e.type, agent: a, to: author || null, text: `◎ buddy check on ${author}'s ${q(title(e.task, p))} ${Number(p.pct) ? `at ${str(p.pct)}%` : "mid-task"}: ${ok ? "looks good" : late ? "notes came back after it landed (kept in shared memory)" : "notes sent mid-task"}`, detail: ok ? undefined : oneLine(notes, 400) };
689
+ add(it);
690
+ if (!ok) {
691
+ talked(a, author);
692
+ thread(it);
693
+ }
694
+ break;
695
+ }
696
+ // A note that needed an answer, settled when its task finished (src/room/followup.ts).
697
+ case "note.closed": {
698
+ const to = pick(p, "to") || e.agent || "";
699
+ const by = pick(p, "by");
700
+ const how = pick(p, "how") || "ignored";
701
+ const kind = pick(p, "kind") || "buddy";
702
+ collab.followups.push({ to, by, kind, how, note: pick(p, "body"), ...(title(e.task) ? { task: title(e.task) } : {}) });
703
+ const what = kind === "radar" ? "the radar's finding" : `${by}'s buddy note`;
704
+ const verb = how === "replied" ? "answers" : "addresses (in code)";
705
+ add({
706
+ ts,
707
+ kind: how === "ignored" ? "fail" : "review",
708
+ type: e.type,
709
+ agent: to || null,
710
+ to: kind === "radar" ? null : by || null,
711
+ text: how === "ignored" ? `${kind === "radar" ? "radar finding" : "buddy note"} ignored: ${kind === "radar" ? "" : `${by}'s note `}on ${q(title(e.task))} got no answer` : `${verb} ${what} on ${q(title(e.task))}`,
712
+ detail: oneLine(pick(p, "body"), 300) || undefined,
713
+ });
714
+ break;
715
+ }
716
+ case "followup": // the scheduler's copy of note.closed
717
+ case "note.open": // the note itself is an agent.message; run.checkpoint / run.radar tell it
718
+ case "buddy.start":
719
+ case "buddy.end":
720
+ break;
721
+ case "done.refused": {
722
+ const a = e.agent;
723
+ add({ ts, kind: "review", type: e.type, agent: a, text: `huddle_done refused once: ${list(p.notes).length || "some"} teammate note(s) on ${q(title(e.task))} had no answer yet` });
724
+ break;
725
+ }
726
+ case "pair.dropped": {
727
+ const w = pick(p, "with");
728
+ add({ ts, kind: "takeover", type: e.type, agent: e.agent, to: w || null, text: `⇉ offered to pair with ${w} on ${q(title(e.task, p))} · dropped`, detail: oneLine(pick(p, "reason"), 200) || undefined });
729
+ break;
730
+ }
731
+ case "pair": {
732
+ const a = pick(p, "agent") || e.agent;
733
+ const w = pick(p, "with");
734
+ collab.pairs.push({ agent: a ?? "", with: w, part: pick(p, "part") });
735
+ add({ ts, kind: "takeover", type: e.type, agent: a, to: w || null, text: `⇉ pairs with ${w} on ${q(title(e.task, p))}: takes ${oneLine(pick(p, "part"), 160)}`, detail: oneLine(pick(p, "reason"), 200) || undefined });
736
+ talked(a, w);
737
+ break;
738
+ }
739
+ case "council.adopt":
740
+ case "council.flaw": {
741
+ // Cross-review: what one agent took from (or found wrong in) another's proposal.
742
+ const a = pick(p, "agent") || e.agent;
743
+ const of = pick(p, "of");
744
+ const it = { ts, kind: "design", type: e.type, agent: a, to: of || null, text: `${e.type === "council.adopt" ? "adopts" : "finds a flaw in"} ${of}'s idea: ${oneLine(pick(p, "body", "text"), 300)}` };
745
+ add(it);
746
+ talked(a, of);
747
+ (e.type === "council.adopt" ? collab.adopts : collab.flaws).push({ by: a ?? "", of, text: pick(p, "body", "text") });
748
+ break;
749
+ }
750
+ case "council.ratify": {
751
+ const a = pick(p, "agent") || e.agent;
752
+ const obj = list(p.objections);
753
+ collab.ratify.push({ agent: a ?? "", verdict: pick(p, "verdict") || (obj.length ? "object" : "ok"), objections: obj });
754
+ add({ ts, kind: "design", type: e.type, agent: a, text: obj.length ? `objects to the contract: ${oneLine(obj.join(" · "), 300)}` : `ratifies the contract${pick(p, "verdict") === "unclear" ? " (no clear verdict)" : ""}` });
755
+ break;
756
+ }
757
+ case "contract.set":
758
+ if (pick(p, "text"))
759
+ collab.contract = { by: pick(p, "by") || e.agent || "council", chars: str(p.text).length };
760
+ if (pick(p, "text"))
761
+ add({ ts, kind: "design", type: e.type, agent: pick(p, "by") || e.agent, text: `writes the contract (${str(p.text).length} chars): the names every task codes to` });
762
+ break;
763
+ case "contract.amended":
764
+ collab.amendments.push({ by: pick(p, "by") || e.agent || "?", version: Number(p.version) || 0, change: `${pick(p, "section")}: ${pick(p, "change")}` });
765
+ add({ ts, kind: "other", type: e.type, agent: pick(p, "by") || e.agent, text: `amends the contract (v${str(p.version)}): ${oneLine(`${pick(p, "section")}: ${pick(p, "change")}`, 200)}`, detail: oneLine(pick(p, "why"), 200) || undefined });
766
+ break;
767
+ case "run.contract": // the Room's contract.amended says the same
768
+ break;
769
+ case "council.skipped": {
770
+ const a = pick(p, "agent") || e.agent;
771
+ add({ ts, kind: "design", type: e.type, agent: a, text: `${pick(p, "round") || "council"} skipped (${pick(p, "reason") || "no answer"})` });
772
+ break;
773
+ }
774
+ case "qa.done": {
775
+ const a = e.agent || pick(p, "agent");
776
+ if (isAgent(a))
777
+ close(a, ts, p.ok === false ? "bad" : "ok");
778
+ add({ ts, kind: "qa", type: e.type, agent: a, text: `${p.ok === false ? "! QA check didn't finish" : "✓ QA check done"} · ${str(p.bugs ?? 0)} bug(s) filed`, detail: p.ms ? fmtDuration(Number(p.ms)) : undefined });
779
+ break;
780
+ }
781
+ case "qa.duplicate":
782
+ add({ ts, kind: "qa", type: e.type, agent: pick(p, "by") || e.agent, text: `= ${q(pick(p, "title"))} is a duplicate of ${q(title(pick(p, "of")))} · not filed again` });
783
+ break;
784
+ case "qa.fixes":
785
+ add({ ts, kind: "qa", type: e.type, agent: null, text: `QA round ${str(p.round)} fixes: ${str(p.merged ?? 0)} merged, ${str(p.failed ?? 0)} failed` });
786
+ break;
787
+ case "qa.capped":
788
+ add({ ts, kind: "qa", type: e.type, agent: null, text: `! QA stopped after round ${str(p.round)} · the last fixes weren't re-checked${Number(p.open) ? ` · ${str(p.open)} still open` : ""}` });
789
+ break;
790
+ case "qa.round": {
791
+ const a = pick(p, "agent", "by", "checker") || list(p.agents)[0] || e.agent;
792
+ if (isAgent(a))
793
+ begin({ agent: a, from: ts, kind: "qa", label: "QA" });
794
+ add({ ts, kind: "qa", type: e.type, agent: a, text: `◎ QA round${p.round !== undefined ? " " + str(p.round) : ""} on the integrated main${list(p.agents).length ? ` · checked by ${list(p.agents).join(", ")} ${qaEyes(list(p.agents), p.authors)}` : ""}`, detail: oneLine(pick(p, "summary", "checks", "note"), 200) || undefined });
795
+ break;
796
+ }
797
+ case "qa.bug": {
798
+ const a = pick(p, "agent", "by", "found_by", "reporter") || e.agent;
799
+ st(a).bugsFound++;
800
+ if (isAgent(a))
801
+ close(a, ts, "warn");
802
+ const owner2 = pick(p, "to", "owner", "assigned_to", "author");
803
+ collab.bugs.push({ by: a ?? "", to: owner2, title: pick(p, "title", "summary", "bug", "body") });
804
+ add({ ts, kind: "qa", type: e.type, agent: a, to: owner2 || null, text: `✗ QA found: ${oneLine(pick(p, "title", "summary", "bug", "body"), 200)}${owner2 ? ` → fix by ${owner2}` : ""}`, detail: oneLine(pick(p, "details", "repro", "spec", "body"), 300) || undefined });
805
+ talked(a, owner2);
806
+ break;
807
+ }
808
+ case "qa.suspect":
809
+ add({ ts, kind: "qa", type: e.type, agent: null, text: `? QA round ${str(p.round)} clean, but quick · ${str(p.reason)}` });
810
+ break;
811
+ case "qa.unchecked":
812
+ add({ ts, kind: "qa", type: e.type, agent: null, text: `! QA round ${str(p.round)}: the build was NOT checked · ${str(p.reason) || "no check finished"}` });
813
+ break;
814
+ case "qa.clean": {
815
+ const a = pick(p, "agent", "by") || e.agent;
816
+ if (isAgent(a))
817
+ close(a, ts, "ok");
818
+ add({ ts, kind: "qa", type: e.type, agent: a, text: `✓ QA${p.round !== undefined ? " round " + str(p.round) : ""} clean${pick(p, "summary", "note") ? " · " + oneLine(pick(p, "summary", "note"), 200) : ""}` });
819
+ break;
820
+ }
821
+ case "qa.fixed":
822
+ case "qa.fix": {
823
+ const a = pick(p, "agent", "by", "fixed_by") || e.agent;
824
+ st(a).bugsFixed++;
825
+ const fixed = collab.bugs.find((b) => !b.fixedBy && b.title === (pick(p, "title", "summary", "bug", "body") || title(e.task)));
826
+ if (fixed && a)
827
+ fixed.fixedBy = a;
828
+ add({ ts, kind: "qa", type: e.type, agent: a, text: `✓ fixed: ${oneLine(pick(p, "title", "summary", "bug", "body") || title(e.task), 200)}` });
829
+ break;
830
+ }
831
+ default: {
832
+ // Unknown (or newer) event: shown with every field, so nothing is lost.
833
+ const a = pick(p, "agent", "from", "by") || e.agent;
834
+ const fields = Object.entries(p)
835
+ .filter(([k, v]) => v !== null && v !== undefined && v !== "" && k !== "type")
836
+ .map(([k, v]) => `${k}=${oneLine(str(v), 80)}`)
837
+ .join(" ");
838
+ const kind = e.type.startsWith("council.") ? "design" : e.type.startsWith("qa.") ? "qa" : e.type.startsWith("takeover") || e.type.startsWith("split") ? "takeover" : "other";
839
+ if (/^qa\.(fix|fixed)/.test(e.type))
840
+ st(a).bugsFixed++;
841
+ add({ ts, kind, type: e.type, agent: a || null, to: pick(p, "to") || null, text: `[${e.type}]${e.task ? " " + q(title(e.task)) : ""}`, detail: fields || undefined });
842
+ see(a);
843
+ }
844
+ }
845
+ }
846
+ for (const a of [...open.keys()])
847
+ close(a, run.end);
848
+ // A QA bug is fixed when its "Fix: <title>" task merged (the title may be cut on the board).
849
+ for (const b of collab.bugs) {
850
+ const want = oneLine(b.title, 60).replace(/…$/, "");
851
+ const hit = mergedTitles.find((m) => m.title.startsWith("Fix: ") && m.title.slice(5).startsWith(want.slice(0, 40)));
852
+ if (hit)
853
+ b.fixedBy = hit.agent;
854
+ }
855
+ // Who proposed what: a council plan's tasks may say whose idea each one was.
856
+ for (const t of councilTasks) {
857
+ const pt = plan.tasks.find((x) => x.title === pick(t ?? {}, "title"));
858
+ const from = list((t ?? {}).proposed_by ?? (t ?? {}).by ?? (t ?? {}).from ?? (t ?? {}).author);
859
+ const primary = pick(t ?? {}, "primary") || from[0] || "";
860
+ if (primary)
861
+ collab.credit.set(primary, (collab.credit.get(primary) ?? 0) + 1);
862
+ const rest = from.filter((x) => x !== primary);
863
+ const who = primary ? `${primary}${rest.length ? ` (with ${rest.join(", ")})` : ""}` : "";
864
+ if (pt && who && !pt.by)
865
+ pt.by = who;
866
+ }
867
+ return { run, runs, agents, plan, design, items, spans, stats, talk, threads, screen: [...screen.values()], merged, failed, collab };
868
+ }
869
+ // ---- teamwork summary (terminal + HTML) -------------------------------------------
870
+ /** The run's collaboration as labelled lines; [] when nobody worked with anybody. */
871
+ export function collabLines(s) {
872
+ const c = s.collab;
873
+ const out = [];
874
+ const n = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
875
+ for (const a of c.adopts.slice(0, 6))
876
+ out.push({ label: "adopted", text: `${a.by} took ${a.of}'s idea: ${oneLine(a.text, 110)}` });
877
+ if (c.adopts.length > 6)
878
+ out.push({ label: "adopted", text: `…and ${c.adopts.length - 6} more` });
879
+ for (const f of c.flaws.slice(0, 6))
880
+ out.push({ label: "flaw", text: `${f.by} on ${f.of}'s idea: ${oneLine(f.text, 110)}` });
881
+ if (c.flaws.length > 6)
882
+ out.push({ label: "flaw", text: `…and ${c.flaws.length - 6} more` });
883
+ if (c.synthesizer)
884
+ out.push({ label: "plan by", text: `${c.synthesizer.id}${c.synthesizer.why ? ` — ${oneLine(c.synthesizer.why, 110)}` : ""}` });
885
+ if (c.ratify.length) {
886
+ const ok = c.ratify.filter((r) => r.verdict !== "object").map((r) => r.agent);
887
+ const obj = c.ratify.filter((r) => r.verdict === "object");
888
+ out.push({ label: "ratify", text: [ok.length ? `${ok.join(", ")} ok` : "", ...obj.map((r) => `${r.agent} objects: ${oneLine(r.objections.join(" · "), 90)}`)].filter(Boolean).join(" · ") });
889
+ }
890
+ for (const o of c.objections.slice(0, 4))
891
+ out.push({ label: "revision", text: `${o.taken ? "took" : "kept the plan over"} ${o.by}'s objection: ${oneLine(o.objection, 100)}` });
892
+ if (c.contract || c.amendments.length) {
893
+ const v = c.amendments.length ? Math.max(...c.amendments.map((a) => a.version)) : 1;
894
+ out.push({ label: "contract", text: `v${v}${c.contract ? ` · written by ${c.contract.by}` : " · started mid-run"} · ${n(c.amendments.length, "amendment")}` });
895
+ for (const a of c.amendments.slice(-4))
896
+ out.push({ label: "", text: `v${a.version} ${a.by}: ${oneLine(a.change, 110)}` });
897
+ }
898
+ if (!c.radar.length && c.radarLooks?.looks)
899
+ out.push({ label: "radar", text: `no catches · compared 2+ agents' work in progress ${n(c.radarLooks.looks, "time")} (${n(c.radarLooks.pairs, "pair")})` });
900
+ if (c.radar.length) {
901
+ out.push({ label: "radar", text: `${n(c.radar.length, "catch", "catches")} while agents worked` });
902
+ for (const r of c.radar.slice(0, 4))
903
+ out.push({ label: "", text: `${r.kind === "overlap" ? "overlap" : "mismatch"} ${r.agent} ↔ ${r.with}: ${oneLine(r.detail, 100)}` });
904
+ }
905
+ if (c.buddy.length) {
906
+ const sent = c.buddy.filter((b) => b.notes);
907
+ const late = sent.filter((b) => b.late).length;
908
+ out.push({ label: "buddy", text: `${n(c.buddy.length, "check")} mid-task · ${n(sent.length, "note")} sent${late ? ` (${late} came back after the task landed)` : ""}` });
909
+ for (const b of sent.slice(0, 4))
910
+ out.push({ label: "", text: `${b.agent} → ${b.author} at ${b.pct}%: ${oneLine(b.notes, 100)}` });
911
+ }
912
+ if (c.followups.length) {
913
+ const k = (h) => c.followups.filter((f) => f.how === h).length;
914
+ const ignored = c.followups.filter((f) => f.how === "ignored");
915
+ out.push({ label: "answered", text: `${n(c.followups.length - ignored.length, "note")} addressed (${k("fixed")} fixed, ${k("replied")} replied) · ${ignored.length} ignored` });
916
+ for (const f of ignored.slice(0, 4))
917
+ out.push({ label: "", text: `ignored by ${f.to}: ${f.kind === "radar" ? "radar" : f.by}: ${oneLine(f.note.replace(/^buddy check from [^:]*:\s*/i, ""), 100)}` });
918
+ }
919
+ for (const p of c.pairs.slice(0, 4))
920
+ out.push({ label: "pair", text: `${p.agent} helped ${p.with}: ${oneLine(p.part, 110)}` });
921
+ if (c.credit.size && (c.adopts.length || c.credit.size > 1))
922
+ out.push({ label: "credit", text: [...c.credit.entries()].sort((a, b) => b[1] - a[1]).map(([a, k]) => `${a} ${n(k, "task")}`).join(" · ") });
923
+ return out;
924
+ }
925
+ function crewRows(s) {
926
+ const done = s.items.filter((it) => it.type === "qa.done");
927
+ return s.agents.map((a) => {
928
+ const x = s.stats.get(a);
929
+ const qa = done.length ? done.filter((it) => it.agent === a).length : s.items.filter((it) => it.type === "qa.round" && it.agent === a).length;
930
+ return { agent: a, merged: x.merged, failed: x.failed, reviews: x.reviews, qa, ms: x.ms, tokens: x.tokens, cached: x.cached };
931
+ });
932
+ }
933
+ const plural = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
934
+ /** "5 merged · 3 reviews · 1 QA" — what an agent did, nothing for what it didn't. */
935
+ function didLine(c) {
936
+ const parts = [`${c.merged} merged`, c.failed ? `${c.failed} failed` : "", c.reviews ? plural(c.reviews, "review") : "", c.qa ? `${c.qa} QA` : ""];
937
+ return parts.filter(Boolean).join(" · ");
938
+ }
939
+ /** The run cut into its phases: the build, then QA (from the first QA round on). */
940
+ function phases(s) {
941
+ // The council's decisions and risks, written to shared memory as the build starts: part of the plan.
942
+ const agreed = s.items.filter((it) => it.agent === "council" && it.type === "memory.added");
943
+ const rest = s.items.filter((it) => !agreed.includes(it));
944
+ const i = rest.findIndex((it) => it.type === "qa.round");
945
+ return i < 0 ? { agreed, build: rest, qa: [] } : { agreed, build: rest.slice(0, i), qa: rest.slice(i) };
946
+ }
947
+ /** A council decision or risk from shared memory, as "decided" / "risk (by)". */
948
+ function agreement(it) {
949
+ const risk = /^gotcha: council risk:/.test(it.text);
950
+ return { risk, by: risk ? /flagged by (\S+)/.exec(it.detail ?? "")?.[1] : undefined, body: it.text.replace(/^\w+: council( risk)?:\s*/, "") };
951
+ }
952
+ /** The council is recorded in one go when it's done: its items share one instant, so they get no clock. */
953
+ const councilInstant = (s) => s.design.length > 1 && s.design.at(-1).ts - s.design[0].ts < 2000;
954
+ /** How the run ended: totals, merges gated on tests, QA's last word. */
955
+ function outcome(s) {
956
+ const rows = crewRows(s);
957
+ const tokens = rows.reduce((n, c) => n + c.tokens, 0);
958
+ const cached = rows.reduce((n, c) => n + c.cached, 0);
959
+ const merges = s.items.filter((it) => it.type === "run.merged");
960
+ const green = merges.filter((it) => /tests? (green|pass)/i.test(it.detail ?? "")).length;
961
+ const last = [...s.items].reverse().find((it) => ["qa.clean", "qa.capped", "qa.unchecked"].includes(it.type));
962
+ const qa = last ? { text: last.text.replace(/^[✓!]\s*/, ""), tone: last.type === "qa.clean" ? "ok" : "warn" } : null;
963
+ const rounds = s.items.filter((it) => it.type === "qa.round").length;
964
+ return { tokens, cached, merges: merges.length, green, qa, rounds, ms: s.run.end - s.run.start };
965
+ }
966
+ /** The teamwork as numbers, for the summary line and the HTML tiles. Only what happened (or was looked for). */
967
+ function tally(s) {
968
+ const c = s.collab;
969
+ const out = [];
970
+ if (c.adopts.length || c.flaws.length) {
971
+ out.push({ n: String(c.adopts.length), label: c.adopts.length === 1 ? "idea adopted" : "ideas adopted", tone: "ok" });
972
+ out.push({ n: String(c.flaws.length), label: c.flaws.length === 1 ? "flaw flagged" : "flaws flagged" });
973
+ }
974
+ if (c.objections.length)
975
+ out.push({ n: `${c.objections.filter((o) => o.taken).length}/${c.objections.length}`, label: "objections taken", tone: "ok" });
976
+ if (c.contract || c.amendments.length) {
977
+ const v = c.amendments.length ? Math.max(...c.amendments.map((a) => a.version)) : 1;
978
+ out.push({ n: `v${v}`, label: `contract, ${plural(c.amendments.length, "amendment")}`, text: `contract v${v} (${plural(c.amendments.length, "amendment")})` });
979
+ }
980
+ if (c.buddy.length)
981
+ out.push({ n: String(c.buddy.length), label: `buddy checks, ${plural(c.buddy.filter((b) => b.notes).length, "note")}` });
982
+ if (c.followups.length) {
983
+ const ignored = c.followups.filter((f) => f.how === "ignored").length;
984
+ out.push({ n: `${c.followups.length - ignored}/${c.followups.length}`, label: "notes answered", tone: ignored ? "warn" : "ok" });
985
+ }
986
+ if (c.radar.length || c.radarLooks?.looks)
987
+ out.push({ n: String(c.radar.length), label: c.radar.length === 1 ? "radar catch" : "radar catches", tone: c.radar.length ? "warn" : undefined });
988
+ if (c.pairs.length)
989
+ out.push({ n: String(c.pairs.length), label: c.pairs.length === 1 ? "pair" : "pairs" });
990
+ if (c.bugs.length) {
991
+ const fixed = c.bugs.filter((b) => b.fixedBy).length;
992
+ out.push({ n: `${c.bugs.length}→${fixed}`, label: "QA bugs → fixed", tone: fixed === c.bugs.length ? "ok" : "warn", text: `${plural(c.bugs.length, "QA bug")}, ${fixed} fixed` });
993
+ }
994
+ const msgs = [...s.stats.values()].reduce((n, x) => n + x.sent, 0);
995
+ if (msgs)
996
+ out.push({ n: String(msgs), label: msgs === 1 ? "message between agents" : "messages between agents" });
997
+ return out;
998
+ }
999
+ /** Each buddy note with how it ended: fixed in code, answered, ignored — or just sent. */
1000
+ function buddyNotes(s) {
1001
+ const left = s.collab.followups.filter((f) => f.kind !== "radar");
1002
+ return s.collab.buddy
1003
+ .filter((b) => b.notes)
1004
+ .map((b) => {
1005
+ const i = left.findIndex((f) => f.by === b.agent && f.to === b.author);
1006
+ const f = i >= 0 ? left.splice(i, 1)[0] : undefined;
1007
+ const how = f ? (f.how === "ignored" ? "ignored" : f.how === "replied" ? "answered" : "fixed in code") : b.late ? "came back late" : "sent";
1008
+ const tone = f ? (f.how === "ignored" ? "bad" : "ok") : b.late ? "warn" : "";
1009
+ return { ...b, how, tone };
1010
+ });
1011
+ }
1012
+ // ---- terminal -----------------------------------------------------------------
1013
+ const clock = (ms) => {
1014
+ const s = Math.max(0, Math.round(ms / 1000));
1015
+ return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
1016
+ };
1017
+ const kindMark = {
1018
+ design: (s) => s,
1019
+ plan: C.violet,
1020
+ task: (s) => s,
1021
+ progress: C.faint,
1022
+ announce: C.faint,
1023
+ message: C.bold,
1024
+ memory: C.faint,
1025
+ handoff: C.violet,
1026
+ review: C.violet,
1027
+ merge: C.green,
1028
+ fail: C.red,
1029
+ conflict: C.yellow,
1030
+ takeover: C.yellow,
1031
+ qa: C.yellow,
1032
+ team: C.faint,
1033
+ other: C.faint,
1034
+ };
1035
+ /** Parts joined with " · ", wrapped onto as many lines as `width` needs. */
1036
+ function wrapParts(parts, width, indent = " ") {
1037
+ const out = [];
1038
+ let cur = "";
1039
+ for (const p of parts) {
1040
+ const next = cur ? `${cur} · ${p}` : p;
1041
+ if (cur && indent.length + next.length > width) {
1042
+ out.push(indent + cur);
1043
+ cur = p;
1044
+ }
1045
+ else
1046
+ cur = next;
1047
+ }
1048
+ if (cur)
1049
+ out.push(indent + cur);
1050
+ return out;
1051
+ }
1052
+ /**
1053
+ * The story in the terminal, in the live run's words: COUNCIL → PLAN → BUILD →
1054
+ * QA → RESULT. `width` (the terminal's columns) cuts every line to fit; without
1055
+ * it (a file, a pipe) lines are kept whole.
1056
+ */
1057
+ export function renderText(s, opts = {}) {
1058
+ const out = [];
1059
+ const r = s.run;
1060
+ const W = opts.width ?? 120;
1061
+ const nameW = Math.max(6, ...s.agents.map((a) => a.length));
1062
+ const who = (a) => (a ? `${dot(a)} ${C.bold(a)}` : `${C.violet("◆")} ${C.faint("huddle")}`);
1063
+ const whoPad = (a) => (a ? `${dot(a)} ${C.bold(pad(a, nameW))}` : `${C.violet("◆")} ${C.faint(pad("huddle", nameW))}`);
1064
+ const indent = " ".repeat(2 + 6 + 1 + 2 + nameW + 1);
1065
+ const instant = councilInstant(s);
1066
+ const line = (it) => {
1067
+ const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : whoPad(it.agent);
1068
+ const tag = it.kind === "message" && it.detail ? C.faint(` (${it.detail})`) : "";
1069
+ const t = it.kind === "design" && instant ? "" : clock(it.ts - r.start);
1070
+ out.push(` ${C.faint(pad(t, 6))} ${head} ${kindMark[it.kind](it.text)}${tag}`);
1071
+ // Details that only repeat the task's title or a why, and specs of new tasks, stay in the HTML.
1072
+ const quiet = it.kind === "message" || it.kind === "progress" || it.type === "memory.added" || it.type === "task.added";
1073
+ if (it.detail && !quiet)
1074
+ for (const l of it.detail.split("\n").slice(0, 6))
1075
+ out.push(C.faint(`${indent}${l}`));
1076
+ };
1077
+ const head = (name, sub = "") => out.push("", `${C.violet(name)}${sub ? C.faint(" · " + sub) : ""}`);
1078
+ const o = outcome(s);
1079
+ const crew = crewRows(s);
1080
+ out.push(`${C.violet("STORY")} ${C.bold(JSON.stringify(goalTitle(r.goal)))}`);
1081
+ out.push(C.faint(` run ${r.index} of ${s.runs}${r.id ? " · " + r.id : ""} · ${new Date(r.start).toLocaleString()} · ${fmtDuration(o.ms)} · ${plural(s.agents.length, "agent")}`));
1082
+ const mark = s.failed ? C.yellow("◎") : C.green("✓");
1083
+ out.push(` ${mark} ${s.merged} merged, ${s.failed} failed` +
1084
+ C.faint(`${o.merges ? ` · ${o.green}/${o.merges} merged on green tests` : ""}${o.tokens ? ` · ${fmtTokens(o.tokens)} tok` : ""}`));
1085
+ if (o.qa)
1086
+ out.push(` ${o.qa.tone === "ok" ? C.green("✓") : C.yellow("!")} ${o.qa.text}`);
1087
+ for (const c of crew)
1088
+ out.push(` ${dot(c.agent)} ${pad(c.agent, nameW)} ${didLine(c)}${C.faint(`${c.ms ? " · " + fmtDuration(c.ms) : ""}${c.tokens ? " · " + fmtTokens(c.tokens) + " tok" : ""}`)}`);
1089
+ const team = collabLines(s);
1090
+ const nums = tally(s);
1091
+ if (team.length || nums.length) {
1092
+ head("TEAMWORK", "who built on whom");
1093
+ out.push(...wrapParts(nums.map((t) => t.text ?? `${t.n} ${t.label}`), W).map((l) => C.bold(l)));
1094
+ // The terminal shows the first few ideas and flaws; the HTML has them all.
1095
+ const total = (label) => team.filter((x) => x.label === label).reduce((n, x) => n + (/^…and (\d+) more$/.exec(x.text) ? Number(/\d+/.exec(x.text)[0]) : 1), 0);
1096
+ const shown = new Map();
1097
+ for (const l of team) {
1098
+ const capped = l.label === "adopted" || l.label === "flaw";
1099
+ const k = (shown.get(l.label) ?? 0) + 1;
1100
+ shown.set(l.label, k);
1101
+ if (capped && k > 3) {
1102
+ if (k === 4)
1103
+ out.push(` ${C.faint(pad(l.label, 9))} ${C.faint(`…and ${total(l.label) - 3} more`)}`);
1104
+ continue;
1105
+ }
1106
+ out.push(` ${C.faint(pad(l.label, 9))} ${l.text}`);
1107
+ }
1108
+ }
1109
+ if (s.design.length) {
1110
+ const members = [...new Set(s.design.map((it) => it.agent).filter(Boolean))];
1111
+ head("COUNCIL", `${members.length ? members.join(", ") + " " : ""}designed it before anyone coded`);
1112
+ s.design.forEach(line);
1113
+ }
1114
+ if (s.plan.tasks.length) {
1115
+ out.push("", `${C.violet("PLAN")}${s.plan.by ? C.faint(" · by " + s.plan.by) : ""}${s.plan.note ? C.faint(" · " + s.plan.note) : ""} ${C.faint(`(${s.plan.tasks.length} task${s.plan.tasks.length > 1 ? "s" : ""})`)}`);
1116
+ const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
1117
+ s.plan.tasks.forEach((t, i) => {
1118
+ const deps = t.deps.map((d) => "#" + (num.get(d) ?? d));
1119
+ out.push(` ${C.faint(String(i + 1).padStart(2) + ".")} ${t.title}` +
1120
+ C.faint([deps.length ? `after ${deps.join(", ")}` : "", t.prefers.length ? `suits ${t.prefers.join("/")}` : "", t.files.length ? t.files.slice(0, 4).join(", ") : "", t.by ? `idea from ${t.by}` : ""]
1121
+ .filter(Boolean)
1122
+ .map((x) => " · " + x)
1123
+ .join("")));
1124
+ });
1125
+ const c = s.collab;
1126
+ for (const it of phases(s).agreed) {
1127
+ const a = agreement(it);
1128
+ out.push(a.risk ? ` ${C.yellow("!")} ${C.faint(`risk${a.by ? ` (${a.by})` : ""}:`)} ${a.body}` : ` ${C.violet("◆")} ${C.faint("decided:")} ${a.body}`);
1129
+ }
1130
+ if (c.contract) {
1131
+ const taken = c.objections.filter((x) => x.taken).length;
1132
+ out.push(` ${C.violet("◆")} contract by ${c.contract.by}${C.faint(`${c.objections.length ? ` · ${taken}/${c.objections.length} objections taken` : ""} · ${plural(c.amendments.length, "amendment")} during the build`)}`);
1133
+ }
1134
+ }
1135
+ if (s.screen.length) {
1136
+ head("SHARED SCREEN", "one copy every agent reads");
1137
+ for (const x of s.screen) {
1138
+ out.push(` ${C.bold(x.id)} ${x.kind} “${x.title}”${C.faint([x.size, x.by ? "by " + x.by : "", x.removed ? "removed" : ""].filter(Boolean).map((v) => " · " + v).join(""))}`);
1139
+ out.push(C.faint(` read by: ${x.readers.length ? x.readers.join(", ") : "nobody"}`));
1140
+ for (const n of x.notes)
1141
+ out.push(` 📌 ${C.bold(n.by)} ${oneLine(n.body, 160)}`);
1142
+ }
1143
+ }
1144
+ const ph = phases(s);
1145
+ head("BUILD", "agents code, check each other, merge");
1146
+ if (!s.items.length)
1147
+ out.push(C.faint(" (nothing happened in this run)"));
1148
+ ph.build.forEach(line);
1149
+ if (ph.qa.length) {
1150
+ head("QA", "agents re-check the merged build; bugs become fix tasks");
1151
+ ph.qa.forEach(line);
1152
+ }
1153
+ head("RESULT");
1154
+ out.push(` ${mark} ${s.merged} merged, ${s.failed} failed${C.faint(` · ${fmtDuration(o.ms)}${o.tokens ? ` · ${fmtTokens(o.tokens)} tok` : ""}${o.cached > o.tokens / 2 ? ` (${fmtTokens(o.cached)} cached)` : ""}`)}`);
1155
+ if (o.merges)
1156
+ out.push(` ${o.green === o.merges ? C.green("✓") : C.yellow("!")} ${o.green}/${o.merges} merges passed the tests first`);
1157
+ if (o.qa)
1158
+ out.push(` ${o.qa.tone === "ok" ? C.green("✓") : C.yellow("!")} ${plural(o.rounds, "QA round")} · ${o.qa.text}`);
1159
+ if (s.agents.length) {
1160
+ out.push("", `${C.violet("WHO DID WHAT")} ${C.faint("· msgs sent/got · bugs found/fixed · takeovers in/out")}`);
1161
+ const cols = [
1162
+ ["merged", 7],
1163
+ ["failed", 7],
1164
+ ["reviews", 8],
1165
+ ["msgs", 7],
1166
+ ["bugs", 6],
1167
+ ["takeover", 9],
1168
+ ["tools", 6],
1169
+ ["time", 8],
1170
+ ["tokens", 7],
1171
+ ];
1172
+ const w = nameW + 4; // "● name" + a gap
1173
+ out.push(C.faint(" " + pad("", w) + cols.map(([c, n]) => pad(c, n)).join("")));
1174
+ for (const a of s.agents) {
1175
+ const x = s.stats.get(a);
1176
+ const vals = [
1177
+ x.merged,
1178
+ x.failed,
1179
+ x.reviews + (x.changesAsked ? ` (${x.changesAsked}✎)` : ""),
1180
+ `${x.sent}/${x.received}`,
1181
+ `${x.bugsFound}/${x.bugsFixed}`,
1182
+ `${x.takeoversIn}/${x.takeoversOut}`,
1183
+ x.toolCalls,
1184
+ x.ms ? fmtDuration(x.ms) : "—",
1185
+ x.tokens ? fmtTokens(x.tokens) : "—",
1186
+ ].map(String);
1187
+ out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, cols[i][1])).join("")}`);
1188
+ }
1189
+ out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("· row → column: messages, reviews, critiques, handoffs")}`);
1190
+ const cw = Math.max(8, ...s.agents.map((a) => a.length + 2));
1191
+ out.push(" " + pad("", w) + s.agents.map((a) => pad(a, cw)).join(""));
1192
+ for (const a of s.agents) {
1193
+ const row = s.talk.get(a);
1194
+ out.push(` ${pad(`${dot(a)} ${a}`, w)}${s.agents.map((b) => pad(a === b ? C.faint("·") : row?.get(b) ? C.bold(String(row.get(b))) : C.faint("0"), cw)).join("")}`);
1195
+ }
1196
+ }
1197
+ return (opts.width ? out.map((l) => truncate(l, Math.max(20, opts.width - 1))) : out).join("\n");
1198
+ }
1199
+ export function renderRunList(runs) {
1200
+ if (!runs.length)
1201
+ return C.faint(" no runs yet — type a goal to start.");
1202
+ return runs
1203
+ .map((r) => {
1204
+ const merged = r.events.filter((e) => e.type === "run.merged").length;
1205
+ const failed = r.events.filter((e) => e.type === "run.failed").length;
1206
+ return ` ${C.bold(String(r.index).padStart(3))} ${C.faint(new Date(r.start).toLocaleString())} ${pad(fmtDuration(r.end - r.start), 7)} ${JSON.stringify(goalTitle(r.goal, 100))} ${C.faint(`· ${merged} merged${failed ? `, ${failed} failed` : ""}${r.id ? " · " + r.id : ""}`)}`;
1207
+ })
1208
+ .join("\n");
1209
+ }
1210
+ // ---- HTML (a static, self-contained report file) --------------------------------
1211
+ const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1212
+ const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
1213
+ /** Lines that are the agents thinking out loud, hidden behind a toggle so the story reads first. */
1214
+ const NOISE = (it) => it.kind === "progress" || it.kind === "announce" || it.kind === "memory" || it.type === "memory.added";
1215
+ export function renderHtml(s) {
1216
+ const r = s.run;
1217
+ const c = s.collab;
1218
+ const o = outcome(s);
1219
+ const span = Math.max(1, r.end - r.start);
1220
+ const pct = (ts) => (((ts - r.start) / span) * 100).toFixed(3);
1221
+ const chip = (a) => `<span class="who"><i style="background:${colorOf(a)}"></i>${esc(a ?? "huddle")}</span>`;
1222
+ const badge = (text, tone = "") => `<span class="badge ${tone}">${esc(text)}</span>`;
1223
+ const card = (title, body, sub = "") => `<div class="card"><h3>${esc(title)}${sub ? ` <small>${esc(sub)}</small>` : ""}</h3>${body}</div>`;
1224
+ const empty = (t) => `<p class="none">${esc(t)}</p>`;
1225
+ // ---- header + crew
1226
+ const kpis = [
1227
+ { n: String(s.merged), l: "merged", tone: "ok" },
1228
+ { n: String(s.failed), l: "failed", tone: s.failed ? "bad" : "" },
1229
+ ...(o.merges ? [{ n: `${o.green}/${o.merges}`, l: "merged on green tests", tone: o.green === o.merges ? "ok" : "warn" }] : []),
1230
+ { n: fmtDuration(o.ms), l: "start to finish", tone: "" },
1231
+ ...(o.tokens ? [{ n: fmtTokens(o.tokens), l: "tokens", tone: "" }] : []),
1232
+ ...(c.bugs.length ? [{ n: `${c.bugs.length}→${c.bugs.filter((b) => b.fixedBy).length}`, l: "QA bugs → fixed", tone: "" }] : []),
1233
+ ]
1234
+ .map((k) => `<div class="kpi ${k.tone}"><b>${esc(k.n)}</b><span>${esc(k.l)}</span></div>`)
1235
+ .join("");
1236
+ const crew = crewRows(s)
1237
+ .map((x) => `<div class="agent" style="--c:${colorOf(x.agent)}"><div class="an"><i></i>${esc(x.agent)}</div><div class="as">${esc(didLine(x))}</div><div class="am">${esc([x.ms ? fmtDuration(x.ms) : "", x.tokens ? fmtTokens(x.tokens) + " tok" : ""].filter(Boolean).join(" · ") || "—")}</div></div>`)
1238
+ .join("");
1239
+ // ---- teamwork
1240
+ const tiles = tally(s)
1241
+ .map((t) => `<div class="tile ${t.tone ?? ""}"><b>${esc(t.n)}</b><span>${esc(t.label)}</span></div>`)
1242
+ .join("");
1243
+ const ideaAgents = s.agents.filter((a) => [...c.adopts, ...c.flaws].some((x) => x.by === a || x.of === a));
1244
+ const cell = (by, of) => {
1245
+ if (by === of)
1246
+ return `<td class="self">·</td>`;
1247
+ const ad = c.adopts.filter((x) => x.by === by && x.of === of).length;
1248
+ const fl = c.flaws.filter((x) => x.by === by && x.of === of).length;
1249
+ return `<td>${ad ? `<span class="ad" title="${ad} adopted">${ad}</span>` : ""}${fl ? `<span class="fl" title="${fl} flaw(s)">${fl}</span>` : ""}${!ad && !fl ? `<span class="z">0</span>` : ""}</td>`;
1250
+ };
1251
+ const ideas = ideaAgents.length
1252
+ ? `<div class="scroll"><table class="grid"><tr><th><small>took ↓ · from →</small></th>${ideaAgents.map((a) => `<th>${chip(a)}</th>`).join("")}</tr>${ideaAgents
1253
+ .map((by) => `<tr><th>${chip(by)}</th>${ideaAgents.map((of) => cell(by, of)).join("")}</tr>`)
1254
+ .join("")}</table></div><p class="legend"><span class="ad">n</span> ideas adopted · <span class="fl">n</span> flaws found · row took from / flagged the column's proposal</p>${c.adopts
1255
+ .slice(0, 3)
1256
+ .map((a) => `<div class="quote">${chip(a.by)} took ${chip(a.of)}'s idea: ${esc(oneLine(a.text, 160))}</div>`)
1257
+ .join("")}`
1258
+ : "";
1259
+ const contract = [
1260
+ c.synthesizer ? `<li>${chip(c.synthesizer.id)} wrote the plan${c.synthesizer.why ? ` <small>${esc(oneLine(c.synthesizer.why, 120))}</small>` : ""}</li>` : "",
1261
+ // Each objection is listed with whether it was taken; the ratify rows add only who signed off.
1262
+ ...c.ratify.filter((x) => x.verdict !== "object" || !c.objections.length).map((x) => `<li>${chip(x.agent)} ${x.verdict === "object" ? badge("objected", "warn") + ` ${esc(oneLine(x.objections.join(" · "), 160))}` : badge("ratified", "ok")}</li>`),
1263
+ ...c.objections.map((x) => `<li>${chip(x.by)}'s objection ${x.taken ? badge("taken", "ok") : badge("kept as is")} <span class="detail">${esc(oneLine(x.objection, 160))}</span></li>`),
1264
+ c.contract ? `<li>◆ contract v1 by ${chip(c.contract.by)} <small>${esc(c.contract.chars)} chars · the names every task codes to</small></li>` : "",
1265
+ ...c.amendments.map((a) => `<li>◆ v${esc(a.version)} by ${chip(a.by)} <span class="detail">${esc(oneLine(a.change, 160))}</span></li>`),
1266
+ ]
1267
+ .filter(Boolean)
1268
+ .join("");
1269
+ const notes = buddyNotes(s);
1270
+ const good = c.buddy.filter((b) => !b.notes).length;
1271
+ const buddy = c.buddy.length
1272
+ ? `<ul class="list">${notes
1273
+ .map((b) => `<li>${chip(b.agent)} → ${chip(b.author)} <small>at ${esc(b.pct)}%</small> ${badge(b.how, b.tone)}<div class="detail">${esc(oneLine(b.notes, 240))}</div></li>`)
1274
+ .join("")}${good ? `<li class="mute">✓ ${plural(good, "check")} looked good</li>` : ""}</ul>`
1275
+ : "";
1276
+ const bugs = c.bugs.length
1277
+ ? `<ul class="list">${c.bugs
1278
+ .map((b) => `<li>${chip(b.by)} found ${b.to ? `→ fix for ${chip(b.to)}` : ""} ${b.fixedBy ? badge(`fixed by ${b.fixedBy}`, "ok") : badge("open", "warn")}<div class="detail">${esc(oneLine(b.title, 200))}</div></li>`)
1279
+ .join("")}</ul>`
1280
+ : "";
1281
+ const radarFix = c.followups.filter((f) => f.kind === "radar");
1282
+ const radar = [
1283
+ ...c.radar.map((x) => `<li>⚠ ${chip(x.agent)} ↔ ${x.with && x.with !== "contract" ? chip(x.with) : esc(x.with || "?")} ${badge(x.kind === "overlap" ? "overlap" : "name mismatch", "warn")}<div class="detail">${esc(oneLine(x.detail, 200))}</div></li>`),
1284
+ ...radarFix.map((f) => `<li>${chip(f.to)} ${badge(f.how === "ignored" ? "ignored" : f.how === "replied" ? "answered" : "fixed in code", f.how === "ignored" ? "bad" : "ok")} the radar's finding<div class="detail">${esc(oneLine(f.note, 200))}</div></li>`),
1285
+ ...c.pairs.map((p) => `<li>⇉ ${chip(p.agent)} paired with ${chip(p.with)}<div class="detail">took: ${esc(oneLine(p.part, 200))}</div></li>`),
1286
+ ].join("");
1287
+ const radarNote = !c.radar.length && c.radarLooks?.looks ? `<p class="none">no radar catches · it compared work in progress ${plural(c.radarLooks.looks, "time")}</p>` : "";
1288
+ const team = collabLines(s);
1289
+ const cards = [
1290
+ ideas ? card("Ideas between agents", ideas, "council cross-review") : "",
1291
+ contract ? card("Council → contract", `<ul class="list">${contract}</ul>`) : "",
1292
+ buddy ? card("Buddy notes mid-task", buddy, "and what came of them") : "",
1293
+ bugs ? card("QA bugs → fixes", bugs) : "",
1294
+ radar || radarNote ? card("Radar & pairs", (radar ? `<ul class="list">${radar}</ul>` : "") + radarNote) : "",
1295
+ ]
1296
+ .filter(Boolean)
1297
+ .join("");
1298
+ const teamwork = tiles || cards || team.length
1299
+ ? `<h2>Teamwork <small>who built on whom</small></h2>${tiles ? `<div class="tiles">${tiles}</div>` : ""}${cards ? `<div class="cards">${cards}</div>` : ""}${team.length ? `<details><summary>Every teamwork line (${team.length})</summary><table class="team">${team.map((l) => `<tr><th>${esc(l.label)}</th><td>${esc(l.text)}</td></tr>`).join("")}</table></details>` : ""}`
1300
+ : "";
1301
+ // ---- lanes: one per agent; spans are its tasks/reviews/conflicts/QA; dots are its messages.
1302
+ const ticks = Array.from({ length: 5 }, (_, i) => `<span style="left:${i * 25}%">${esc(clock((span * i) / 4))}</span>`).join("");
1303
+ const lanes = s.agents
1304
+ .map((a) => {
1305
+ const bars = s.spans
1306
+ .filter((x) => x.agent === a)
1307
+ .map((x) => `<div class="bar ${x.kind} ${x.outcome ?? ""}" style="left:${pct(x.from)}%;width:max(4px,${(((x.to - x.from) / span) * 100).toFixed(3)}%);--c:${colorOf(a)}" title="${esc(`${x.kind}: ${x.label} · ${clock(x.from - r.start)}–${clock(x.to - r.start)}`)}"><span>${esc(x.label)}</span></div>`)
1308
+ .join("");
1309
+ const marks = [...s.items, ...s.design]
1310
+ .filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail"].includes(it.kind))
1311
+ .map((it) => `<b class="mark ${it.kind}" style="left:${pct(it.ts)}%" title="${esc(`${clock(it.ts - r.start)} ${it.to ? "→ " + it.to + ": " : ""}${it.text}`)}"></b>`)
1312
+ .join("");
1313
+ return `<div class="lane" data-lane="${esc(a)}"><div class="name">${chip(a)}</div><div class="track">${bars}${marks}</div></div>`;
1314
+ })
1315
+ .join("");
1316
+ // ---- the timeline, by phase
1317
+ const instant = councilInstant(s);
1318
+ const row = (it) => `<li class="${it.kind}${NOISE(it) ? " noise" : ""}"><time>${it.kind === "design" && instant ? "" : esc(clock(it.ts - r.start))}</time><div>${chip(it.agent)}${it.to && it.kind !== "design" ? ` <span class="arrow">→</span> ${chip(it.to)}` : ""} <span class="text">${esc(it.text)}</span>${it.detail ? `<div class="detail">${esc(it.detail)}</div>` : ""}</div></li>`;
1319
+ const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
1320
+ const plan = s.plan.tasks
1321
+ .map((t) => `<li><b>${esc(t.title)}</b>${t.deps.length ? ` <small>after ${esc(t.deps.map((d) => "#" + (num.get(d) ?? d)).join(", "))}</small>` : ""}${t.prefers.length ? ` <small>suits ${esc(t.prefers.join("/"))}</small>` : ""}${t.by ? ` <small>idea from ${esc(t.by)}</small>` : ""}${t.files.length ? `<div class="detail">${esc(t.files.join(", "))}</div>` : ""}</li>`)
1322
+ .join("");
1323
+ const ph = phases(s);
1324
+ const noise = s.items.filter(NOISE).length;
1325
+ const phase = (tag, sub, body) => `<section class="phase"><h3><span class="tag">${esc(tag)}</span> ${esc(sub)}</h3>${body}</section>`;
1326
+ const members = [...new Set(s.design.map((it) => it.agent).filter(Boolean))];
1327
+ const result = [
1328
+ `<li>${s.failed ? "◎" : "✓"} <b>${s.merged} merged, ${s.failed} failed</b> <small>${esc(fmtDuration(o.ms))}${o.tokens ? " · " + esc(fmtTokens(o.tokens)) + " tok" : ""}</small></li>`,
1329
+ o.merges ? `<li>${o.green === o.merges ? "✓" : "!"} ${o.green}/${o.merges} merges passed the tests first</li>` : "",
1330
+ o.qa ? `<li class="${o.qa.tone}">${o.qa.tone === "ok" ? "✓" : "!"} ${esc(plural(o.rounds, "QA round"))} · ${esc(o.qa.text)}</li>` : "",
1331
+ ].join("");
1332
+ const timeline = [
1333
+ s.design.length ? phase("COUNCIL", `${members.join(", ")} designed it before anyone coded`, `<ol class="timeline">${s.design.map(row).join("")}</ol>`) : "",
1334
+ s.plan.tasks.length
1335
+ ? phase("PLAN", `${plural(s.plan.tasks.length, "task")}${s.plan.by ? " · by " + s.plan.by : ""}`, `<ol class="plan">${plan}</ol>${ph.agreed.length
1336
+ ? `<ul class="list agreed">${ph.agreed
1337
+ .map((it) => agreement(it))
1338
+ .map((a) => `<li>${a.risk ? `<span class="badge warn">risk${a.by ? " · " + esc(a.by) : ""}</span>` : `<span class="badge ok">decided</span>`} ${esc(a.body)}</li>`)
1339
+ .join("")}</ul>`
1340
+ : ""}${c.contract ? `<p class="contract">◆ contract by ${chip(c.contract.by)}${c.objections.length ? ` · ${c.objections.filter((x) => x.taken).length}/${c.objections.length} objections taken` : ""} · ${plural(c.amendments.length, "amendment")} during the build</p>` : ""}`)
1341
+ : "",
1342
+ phase("BUILD", "agents code, check each other, merge", `<ol class="timeline">${ph.build.map(row).join("") || '<li class="none">nothing happened in this run</li>'}</ol>`),
1343
+ ph.qa.length ? phase("QA", "agents re-check the merged build; bugs become fix tasks", `<ol class="timeline">${ph.qa.map(row).join("")}</ol>`) : "",
1344
+ phase("RESULT", "", `<ul class="list result">${result}</ul>`),
1345
+ ].join("");
1346
+ // ---- shared screen, conversations, tables
1347
+ const screen = s.screen.length
1348
+ ? `<h2>Shared screen <small>one copy every agent reads</small></h2><ul class="list card">${s.screen
1349
+ .map((x) => `<li><b>${esc(x.id)}</b> ${esc(x.kind)} “${esc(x.title)}”${x.size ? ` <small>${esc(x.size)}</small>` : ""}${x.by ? ` <small>by ${esc(x.by)}</small>` : ""}${x.removed ? ` <small>removed</small>` : ""}<div class="detail">read by: ${x.readers.length ? x.readers.map((a) => chip(a)).join(" ") : "nobody"}</div>${x.notes.map((n) => `<div class="detail">📌 ${chip(n.by)} ${esc(n.body)}</div>`).join("")}</li>`)
1350
+ .join("")}</ul>`
1351
+ : "";
1352
+ const threads = [...s.threads.entries()]
1353
+ .map(([pair, msgs]) => `<details><summary>${esc(pair)} <small>${msgs.length} message${msgs.length > 1 ? "s" : ""}</small></summary><ol class="thread">${msgs
1354
+ .map((m) => `<li class="${m.agent === pair.split(" ↔ ")[0] ? "l" : "r"}" style="--c:${colorOf(m.agent)}"><div class="meta">${esc(m.agent)} → ${esc(m.to)} · ${esc(clock(m.ts - r.start))}${m.detail && m.kind === "message" ? " · " + esc(m.detail) : ""}</div>${esc(m.text)}${m.detail && m.kind !== "message" ? `<div class="detail">${esc(m.detail)}</div>` : ""}</li>`)
1355
+ .join("")}</ol></details>`)
1356
+ .join("");
1357
+ const statRows = s.agents
1358
+ .map((a) => {
1359
+ const x = s.stats.get(a);
1360
+ return `<tr><th>${chip(a)}</th><td>${x.merged}</td><td>${x.failed}</td><td>${x.reviews}${x.changesAsked ? ` (${x.changesAsked}✎)` : ""}</td><td>${x.sent}/${x.received}</td><td>${x.proposals}/${x.critiques}</td><td>${x.bugsFound}/${x.bugsFixed}</td><td>${x.takeoversIn}/${x.takeoversOut}</td><td>${x.toolCalls}</td><td>${x.ms ? esc(fmtDuration(x.ms)) : "—"}</td><td>${x.tokens ? esc(fmtTokens(x.tokens)) : "—"}</td></tr>`;
1361
+ })
1362
+ .join("");
1363
+ const matrix = `<tr><th></th>${s.agents.map((a) => `<th>${esc(a)}</th>`).join("")}</tr>${s.agents
1364
+ .map((a) => `<tr><th>${chip(a)}</th>${s.agents.map((b) => (a === b ? `<td class="self">·</td>` : `<td class="${s.talk.get(a)?.get(b) ? "hot" : ""}">${s.talk.get(a)?.get(b) ?? 0}</td>`)).join("")}</tr>`)
1365
+ .join("")}`;
1366
+ return `<!doctype html>
1367
+ <html lang="en">
1368
+ <head>
1369
+ <meta charset="utf-8">
1370
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1371
+ <title>Huddle story</title>
1372
+ <style>
1373
+ :root{--bg:#f6f5f1;--fg:#1d1b18;--mute:#6f6a62;--line:#e4e0d8;--card:#fff;--soft:#efede7;--ok:#23864c;--bad:#c93f30;--warn:#a86f05;--brand:#6a5af0;color-scheme:light}
1374
+ @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}}
1375
+ :root[data-theme="dark"]{--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}
1376
+ *{box-sizing:border-box}
1377
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
1378
+ main{max-width:1100px;margin:0 auto;padding:24px 16px 60px}
1379
+ h1{font-size:26px;line-height:1.25;margin:6px 0 4px;word-break:break-word}
1380
+ h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--brand);margin:36px 0 12px}
1381
+ h2 small{text-transform:none;letter-spacing:0}
1382
+ h3{font-size:14px;margin:0 0 10px}
1383
+ .brand{font-weight:700;color:var(--brand);font-size:13px;letter-spacing:.04em}
1384
+ .sub,small,.detail,time,.meta,.none,.legend{color:var(--mute)}small{font-size:12px;margin-left:6px;font-weight:400}
1385
+ .none{font-size:13px;margin:6px 0}
1386
+ .who{display:inline-flex;align-items:center;gap:5px;font-weight:600;white-space:nowrap}.who i{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1387
+ .hero{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}
1388
+ .hero details{margin:8px 0}
1389
+ .kpis,.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-top:16px}
1390
+ .kpi,.tile{background:var(--soft);border-radius:12px;padding:10px 12px;min-width:0}
1391
+ .kpi b,.tile b{display:block;font-size:24px;line-height:1.2;font-variant-numeric:tabular-nums}
1392
+ .kpi span,.tile span{font-size:12px;color:var(--mute)}
1393
+ .kpi.ok b,.tile.ok b{color:var(--ok)}.kpi.bad b,.tile.bad b{color:var(--bad)}.kpi.warn b,.tile.warn b{color:var(--warn)}
1394
+ .verdict{margin:14px 0 0;font-weight:600}.verdict.ok{color:var(--ok)}.verdict.warn{color:var(--warn)}
1395
+ .crew{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;margin-top:12px}
1396
+ .agent{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--c);border-radius:12px;padding:10px 14px;min-width:0}
1397
+ .agent .an{display:flex;align-items:center;gap:7px;font-weight:700;font-size:16px}.agent .an i{width:11px;height:11px;border-radius:50%;background:var(--c)}
1398
+ .agent .as{font-size:14px}.agent .am{font-size:12px;color:var(--mute);font-variant-numeric:tabular-nums}
1399
+ .cards{columns:320px 3;column-gap:12px;margin-top:12px}.cards .card{break-inside:avoid;margin-bottom:12px}
1400
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;min-width:0}
1401
+ .list li{font-size:14px;padding:7px 0;border-top:1px solid var(--line);word-break:break-word}.list li:first-child{border-top:0}
1402
+ .list li.mute{color:var(--mute)}
1403
+ .badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:var(--soft);color:var(--mute);white-space:nowrap}
1404
+ .badge.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}.badge.bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 14%,transparent)}.badge.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 16%,transparent)}
1405
+ .grid td,.grid th{text-align:center}.grid th:first-child{text-align:left}
1406
+ .ad,.fl{display:inline-block;min-width:22px;padding:1px 6px;margin:1px;border-radius:6px;font-weight:700;font-size:12px}
1407
+ .ad{color:var(--ok);background:color-mix(in srgb,var(--ok) 15%,transparent)}.fl{color:var(--warn);background:color-mix(in srgb,var(--warn) 17%,transparent)}.z{color:var(--line)}
1408
+ .legend{font-size:12px;margin:8px 0}
1409
+ .quote{font-size:13px;padding:6px 0 6px 10px;border-left:3px solid var(--line);margin-top:6px;word-break:break-word}
1410
+ .scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
1411
+ .lanesbox{border:1px solid var(--line);border-radius:12px;background:var(--card)}
1412
+ .lanes{min-width:640px;padding:8px 12px 12px}
1413
+ .axis{position:relative;height:18px;margin-left:120px;font-size:11px;color:var(--mute)}.axis span{position:absolute;transform:translateX(-50%)}.axis span:first-child{transform:none}.axis span:last-child{transform:translateX(-100%)}
1414
+ .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
1415
+ .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
1416
+ .track{position:relative;flex:1;height:26px}
1417
+ .bar{position:absolute;top:3px;height:20px;border-radius:5px;background:color-mix(in srgb,var(--c) 30%,transparent);border:1px solid var(--c);overflow:hidden;font-size:11px;line-height:18px;padding:0 4px;white-space:nowrap}
1418
+ .bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
1419
+ .bar.ok{box-shadow:inset -3px 0 0 var(--ok)}.bar.bad{box-shadow:inset -3px 0 0 var(--bad)}.bar.warn{box-shadow:inset -3px 0 0 var(--warn)}
1420
+ .mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
1421
+ .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}
1422
+ ol,ul{padding:0;margin:0;list-style:none}
1423
+ .phase{margin-top:22px}
1424
+ .phase h3{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-weight:400;color:var(--mute);font-size:13px}
1425
+ .tag{font-weight:800;letter-spacing:.08em;color:#fff;background:var(--brand);border-radius:6px;padding:2px 8px;font-size:12px}
1426
+ .timeline li{display:flex;gap:10px;padding:6px 0;font-size:14px;border-top:1px solid var(--line);min-width:0}
1427
+ .timeline li>div{min-width:0;word-break:break-word}
1428
+ .timeline time{flex:none;width:46px;font-variant-numeric:tabular-nums;font-size:13px}
1429
+ .timeline .merge .text{color:var(--ok);font-weight:600}.timeline .fail .text{color:var(--bad)}.timeline .conflict .text,.timeline .qa .text,.timeline .takeover .text{color:var(--warn)}
1430
+ .timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text,.timeline .memory .text{color:var(--mute)}
1431
+ #noise{position:absolute;opacity:0;pointer-events:none}
1432
+ .toggle{display:inline-block;font-size:13px;color:var(--mute);border:1px solid var(--line);border-radius:999px;padding:3px 12px;cursor:pointer;user-select:none;margin-top:4px}
1433
+ .toggle::before{content:"○ "}#noise:checked~.toggle::before{content:"● "}
1434
+ #noise:focus-visible~.toggle{outline:2px solid var(--brand)}
1435
+ .timeline li.noise{display:none}#noise:checked~.phases .timeline li.noise{display:flex}
1436
+ .arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
1437
+ .plan{counter-reset:n}.plan li{padding:6px 0;border-top:1px solid var(--line);word-break:break-word}.plan li::before{counter-increment:n;content:counter(n) ". ";color:var(--mute)}
1438
+ .contract{margin:10px 0 0}
1439
+ .result li{font-size:15px}.result li.ok{color:var(--ok)}.result li.warn{color:var(--warn)}
1440
+ details{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
1441
+ .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
1442
+ .thread li{max-width:85%;padding:8px 10px;border-radius:12px;border:1px solid var(--line);border-left:4px solid var(--c);word-break:break-word}
1443
+ .thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
1444
+ table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
1445
+ table.team td{white-space:normal;word-break:break-word}table.team th{color:var(--mute);font-weight:600;vertical-align:top}
1446
+ td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
1447
+ footer{margin-top:40px;font-size:12px;color:var(--mute);text-align:center}
1448
+ @media (max-width:520px){h1{font-size:21px}.hero{padding:16px}.kpi b,.tile b{font-size:20px}.kpis,.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
1449
+ </style>
1450
+ </head>
1451
+ <body>
1452
+ <main>
1453
+ <header class="hero">
1454
+ <div class="brand">⬢ HUDDLE · STORY</div>
1455
+ <h1>${esc(goalTitle(r.goal, 110))}</h1>${goalTitle(r.goal, 110) !== r.goal.trim() ? `<details><summary>The goal</summary><pre style="white-space:pre-wrap;word-break:break-word">${esc(r.goal)}</pre></details>` : ""}
1456
+ <div class="sub">run ${r.index} of ${s.runs}${r.id ? " · " + esc(r.id) : ""} · ${esc(new Date(r.start).toLocaleString())} · ${s.agents.length} agent${s.agents.length === 1 ? "" : "s"}</div>
1457
+ <div class="kpis">${kpis}</div>
1458
+ ${o.qa ? `<p class="verdict ${o.qa.tone}">${o.qa.tone === "ok" ? "✓" : "!"} ${esc(plural(o.rounds, "QA round"))} · ${esc(o.qa.text)}</p>` : ""}
1459
+ </header>
1460
+ ${crew ? `<div class="crew">${crew}</div>` : ""}
1461
+ ${teamwork}
1462
+ <h2>Timeline <small>who was doing what, when</small></h2>
1463
+ <div class="lanesbox scroll"><div class="lanes"><div class="axis">${ticks}</div>${lanes || '<p class="none">no agents in this run</p>'}</div></div>
1464
+ <p class="legend">bars: tasks (solid) · reviews (dashed) · conflict fixes · QA (dotted); right edge green = merged/approved, red = failed/changes, amber = skipped/bug.</p>
1465
+ <input type="checkbox" id="noise"><label class="toggle" for="noise">show progress notes &amp; memory (${noise})</label>
1466
+ <div class="phases">${timeline}</div>
1467
+ ${screen}
1468
+ ${threads ? `<h2>Conversations <small>agent to agent</small></h2>${threads}` : ""}
1469
+ <h2>Who did what</h2>
1470
+ <div class="scroll card"><table><tr><th>agent</th><th>merged</th><th>failed</th><th>reviews</th><th>msgs out/in</th><th>proposals/critiques</th><th>bugs found/fixed</th><th>takeovers in/out</th><th>tool calls</th><th>time</th><th>tokens</th></tr>${statRows}</table></div>
1471
+ <h2>Who talked to whom <small>row → column</small></h2>
1472
+ <div class="scroll card"><table>${matrix}</table></div>
1473
+ <footer>made by huddle story · one file, no network</footer>
1474
+ </main>
1475
+ </body>
1476
+ </html>
1477
+ `;
1478
+ }
1479
+ // ---- CLI ------------------------------------------------------------------------
1480
+ /** `huddle story [--list] [--run <n|id>] [--html <file>]` against this repo's room. */
1481
+ export async function runStory(args) {
1482
+ const { dbPath } = await import("./app.js");
1483
+ const flag = (name) => {
1484
+ const i = args.indexOf(name);
1485
+ if (i >= 0)
1486
+ return args[i + 1];
1487
+ return args.find((a) => a.startsWith(name + "="))?.slice(name.length + 1);
1488
+ };
1489
+ if (!fs.existsSync(dbPath())) {
1490
+ console.log(C.faint(" no runs yet — this repo has no room (.huddle/huddle.db). Type a goal in `huddle` to start."));
1491
+ return;
1492
+ }
1493
+ const { RoomDB } = await import("../room/db.js");
1494
+ const db = new RoomDB(dbPath());
1495
+ let runs;
1496
+ try {
1497
+ runs = splitRuns(db.allEvents());
1498
+ }
1499
+ finally {
1500
+ db.close();
1501
+ }
1502
+ if (args.includes("--list"))
1503
+ return void console.log(renderRunList(runs));
1504
+ const which = flag("--run") ?? args.find((a) => /^-?\d+$/.test(a));
1505
+ const run = pickRun(runs, which);
1506
+ if (!run) {
1507
+ console.log(runs.length ? C.red(`✗ no run ${which}.`) + " See `huddle story --list`." : C.faint(" no runs yet — type a goal to start."));
1508
+ if (runs.length)
1509
+ process.exitCode = 1;
1510
+ return;
1511
+ }
1512
+ const story = buildStory(run, runs.length);
1513
+ const html = flag("--html");
1514
+ if (args.includes("--html") && !html) {
1515
+ console.log(C.red("✗ --html needs a file:") + " huddle story --html story.html");
1516
+ process.exitCode = 1;
1517
+ return;
1518
+ }
1519
+ if (html) {
1520
+ const out = path.resolve(html);
1521
+ fs.mkdirSync(path.dirname(out), { recursive: true });
1522
+ fs.writeFileSync(out, renderHtml(story));
1523
+ console.log(`${C.green("✓")} wrote ${out} ${C.faint(`· one file, no network · ${story.agents.length} lanes, ${story.items.length} events`)}`);
1524
+ return;
1525
+ }
1526
+ // A terminal gets lines cut to its width; a file or a pipe gets them whole.
1527
+ console.log(renderText(story, { width: process.stdout.isTTY ? process.stdout.columns || 80 : undefined }));
1528
+ }
1529
+ //# sourceMappingURL=story.js.map