huddle-cli 0.1.0 → 0.1.3

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 (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
package/dist/ui/story.js CHANGED
@@ -13,11 +13,11 @@
13
13
  // huddle story --html out.html a self-contained static report (lanes, threads)
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
- import { C, dot, pad, AGENT_COLOR } from "./term.js";
16
+ import { C, dot, pad, truncate, AGENT_COLOR } from "./term.js";
17
17
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
18
18
  import { isHelpOffer } from "../room/progress.js";
19
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"]);
20
+ export const CORE_RUN = new Set(["start", "declined", "tokens", "review", "reviewed", "merged", "failed", "stuck", "conflict", "resolved", "handoff"]);
21
21
  const NOT_AGENTS = new Set(["huddle", "room", "all", "you", "council", ""]);
22
22
  function parse(e) {
23
23
  let p = {};
@@ -40,9 +40,9 @@ function parse(e) {
40
40
  delete p.type;
41
41
  return { ts: e.ts, type, agent: e.agent_id, task: e.task_id, p };
42
42
  }
43
- const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
43
+ export const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
44
44
  /** First non-empty field among `keys`. */
45
- const pick = (p, ...keys) => {
45
+ export const pick = (p, ...keys) => {
46
46
  for (const k of keys) {
47
47
  const v = p[k];
48
48
  if (v !== undefined && v !== null && str(v).trim())
@@ -50,7 +50,7 @@ const pick = (p, ...keys) => {
50
50
  }
51
51
  return "";
52
52
  };
53
- const oneLine = (s, n = 140) => {
53
+ export const oneLine = (s, n = 140) => {
54
54
  const l = s.replace(/\s+/g, " ").trim();
55
55
  return l.length <= n ? l : l.slice(0, n - 1) + "…";
56
56
  };
@@ -74,7 +74,7 @@ function qaEyes(checkers, authors) {
74
74
  return "(didn't write it)";
75
75
  return mine.length === checkers.length ? "(wrote some of it too — checking anyway)" : `(${mine.join(", ")} wrote some of it too)`;
76
76
  }
77
- const list = (v) => {
77
+ export const list = (v) => {
78
78
  if (Array.isArray(v))
79
79
  return v.map(str);
80
80
  if (typeof v === "string") {
@@ -118,7 +118,8 @@ export function splitRuns(raw) {
118
118
  });
119
119
  const chunks = starts.map((s, k) => evs.slice(s, k + 1 < starts.length ? starts[k + 1] : evs.length));
120
120
  // The design talk happens before the board is cleared: move it forward.
121
- const isDesign = (e) => e.type.startsWith("council.");
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";
122
123
  const before = evs.slice(0, starts[0]).filter(isDesign);
123
124
  chunks[0] = [...before, ...chunks[0]];
124
125
  for (let k = 1; k < chunks.length; k++) {
@@ -173,9 +174,23 @@ export function buildStory(run, runs = 1) {
173
174
  const plan = { tasks: [] };
174
175
  const open = new Map(); // agent -> its open span
175
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
+ };
176
191
  let merged = 0;
177
192
  let failed = 0;
178
- // The brain that settles a council plan (a label like "claude · sonnet") is not an agent on the crew.
193
+ // The brain that settles a council plan (a label like "claude · opus") is not an agent on the crew.
179
194
  const working = new Set(run.events.filter((e) => e.type === "run.start" || e.type === "agent.joined").map((e) => e.agent));
180
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)));
181
196
  const isAgent = (a) => !!a && !NOT_AGENTS.has(a) && !brains.has(a);
@@ -258,6 +273,35 @@ export function buildStory(run, runs = 1) {
258
273
  case "plug.call":
259
274
  st(e.agent).toolCalls++;
260
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;
261
305
  case "plan.made":
262
306
  plan.by = pick(p, "by") || plan.by;
263
307
  plan.note = pick(p, "note") || plan.note;
@@ -329,6 +373,8 @@ export function buildStory(run, runs = 1) {
329
373
  case "memory.added": {
330
374
  const author = pick(p, "author") || e.agent || "";
331
375
  const body = pick(p, "body");
376
+ if (author === "you")
377
+ break; // your words: already their message or answer
332
378
  if (author === "huddle") {
333
379
  // Mostly the scheduler restating what run.* says; the red gate is news.
334
380
  if (/red test gate/.test(body))
@@ -358,6 +404,18 @@ export function buildStory(run, runs = 1) {
358
404
  // Huddle's automatic help offer, sent in a free agent's name: a board event,
359
405
  // not a message the agent wrote. Found live (capstone round 2): 6 offers
360
406
  // and no real message, yet the table said claude sent 3 messages.
407
+ // The radar's mail: the run.radar item already tells it, once per finding.
408
+ if (from === "huddle" && /^Huddle radar:/.test(pick(p, "body")))
409
+ break;
410
+ // Buddy notes and contract broadcasts: run.checkpoint / contract.amended tell them once.
411
+ if (from === "huddle" && /^buddy check from /.test(pick(p, "body")))
412
+ break;
413
+ if (/^contract v\d+ by /.test(pick(p, "body")))
414
+ break;
415
+ if (from === "huddle" && / is taking “.*” \(files .*\) in \d+s unless you reply/.test(pick(p, "body"))) {
416
+ 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)` });
417
+ break;
418
+ }
361
419
  if (isHelpOffer(from, pick(p, "body"))) {
362
420
  const t = /your task "(.*)" \(/.exec(pick(p, "body"))?.[1];
363
421
  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)` });
@@ -396,6 +454,19 @@ export function buildStory(run, runs = 1) {
396
454
  st(to).received++;
397
455
  break;
398
456
  }
457
+ // An agent's question for the person at the keyboard (huddle_ask_user), and what came back.
458
+ case "user.ask": {
459
+ const from = pick(p, "from") || e.agent || "?";
460
+ const opts = list(p.options);
461
+ add({ ts, kind: "message", type: e.type, agent: from, to: "you", text: oneLine(pick(p, "question"), 400), detail: `question for you · ${opts.map((o, i) => `${i + 1}. ${o}`).join(" · ")}` });
462
+ break;
463
+ }
464
+ case "user.answer": {
465
+ const to = pick(p, "from") || e.agent || "?";
466
+ const mine = pick(p, "by") === "you";
467
+ add({ ts, kind: "message", type: e.type, agent: mine ? "you" : "huddle", to, text: oneLine(pick(p, "answer"), 400), detail: mine ? "your answer" : "no answer in time · went with option 1" });
468
+ break;
469
+ }
399
470
  case "handoff": {
400
471
  const from = pick(p, "from") || e.agent;
401
472
  const to = pick(p, "to");
@@ -486,6 +557,7 @@ export function buildStory(run, runs = 1) {
486
557
  merged++;
487
558
  if (/^Fix: /.test(title(e.task, p)))
488
559
  s.bugsFixed++; // a QA bug's fix task landed
560
+ mergedTitles.push({ title: title(e.task, p), agent: a });
489
561
  close(a, ts, "ok");
490
562
  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(" · ") });
491
563
  break;
@@ -505,6 +577,14 @@ export function buildStory(run, runs = 1) {
505
577
  case "run.stuck":
506
578
  add({ ts, kind: "fail", type: e.type, agent: null, text: `! blocked by failures: ${list(p.blocked).join(" · ")}` });
507
579
  break;
580
+ case "busy": // quiet past the silence window, but working: not taken over (busy.ts)
581
+ add({ ts, kind: "progress", type: e.type, agent: pick(p, "agent") || e.agent, text: `⧗ running a long command: quiet ${Math.round((Number(p.quietMs) || 0) / 60_000) || "<1"} min, but busy — not taken over`, detail: oneLine(pick(p, "why"), 200) || undefined });
582
+ break;
583
+ case "budget": {
584
+ const left = list(p.left);
585
+ add({ ts, kind: "fail", type: e.type, agent: null, text: `! token budget ${p.level === "hard" ? "ran over: agents stopped" : "nearly spent: no new work started"} (${Math.round(((Number(p.tokens) || 0) / (Number(p.limit) || 1)) * 100)}% of ${fmtTokens(Number(p.limit) || 0)})`, detail: left.length ? `left undone: ${left.join(" · ")}` : undefined });
586
+ break;
587
+ }
508
588
  case "run.conflict": {
509
589
  const a = pick(p, "agent") || e.agent || "?";
510
590
  const author = pick(p, "author");
@@ -530,7 +610,11 @@ export function buildStory(run, runs = 1) {
530
610
  const a = pick(p, "agent", "from", "by", "critic") || e.agent;
531
611
  const of = pick(p, "of", "target", "to", "author");
532
612
  st(a).critiques++;
533
- const it = { 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)}` };
613
+ // Council v2: the ADOPT / FLAW lines get their own items (council.adopt / .flaw); here, just where it landed.
614
+ const revised = Array.isArray(p.adopts) || Array.isArray(p.flaws) ? /REVISED\W*:?\s*([\s\S]*)$/i.exec(pick(p, "text"))?.[1] : undefined;
615
+ const it = revised
616
+ ? { ts, kind: "design", type: e.type, agent: a, to: null, text: `revised position: ${oneLine(revised, 300)}` }
617
+ : { 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)}` };
534
618
  add(it);
535
619
  talked(a, of);
536
620
  thread(it);
@@ -541,7 +625,19 @@ export function buildStory(run, runs = 1) {
541
625
  plan.by = plan.by ?? (by ? `${by} (council)` : "council");
542
626
  const tasks = Array.isArray(p.tasks) ? p.tasks : [];
543
627
  councilTasks.push(...tasks);
544
- add({ ts, kind: "design", type: e.type, agent: by, text: `settles the plan${tasks.length || p.tasks ? `: ${tasks.length || str(p.tasks)} task(s)` : ""}${pick(p, "summary", "note") ? " · " + oneLine(pick(p, "summary", "note"), 200) : ""}` });
628
+ const sy = p.synthesizer && typeof p.synthesizer === "object" ? p.synthesizer : null;
629
+ if (sy)
630
+ collab.synthesizer = { id: str(sy.id), kind: str(sy.kind), why: str(sy.why) };
631
+ for (const o of Array.isArray(p.objections) ? p.objections : [])
632
+ collab.objections.push({ by: str(o?.by), objection: str(o?.objection), taken: o?.taken === true });
633
+ add({
634
+ ts,
635
+ kind: "design",
636
+ type: e.type,
637
+ agent: by,
638
+ 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) : ""}`,
639
+ 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,
640
+ });
545
641
  break;
546
642
  }
547
643
  case "task.takeover": {
@@ -564,6 +660,19 @@ export function buildStory(run, runs = 1) {
564
660
  break;
565
661
  case "offer": // the help offer itself is an agent.message in the busy agent's inbox
566
662
  break;
663
+ case "radar.finding": // the Room's copy of run.radar
664
+ break;
665
+ case "radar.stats": {
666
+ const prev = collab.radarLooks ?? { ticks: 0, looks: 0, pairs: 0 };
667
+ collab.radarLooks = { ticks: prev.ticks + (Number(p.ticks) || 0), looks: prev.looks + (Number(p.looks) || 0), pairs: prev.pairs + (Number(p.pairs) || 0) };
668
+ break;
669
+ }
670
+ case "radar": {
671
+ const w = pick(p, "with");
672
+ collab.radar.push({ agent: e.agent ?? pick(p, "agent"), with: w, kind: pick(p, "kind"), detail: pick(p, "detail") });
673
+ 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 });
674
+ break;
675
+ }
567
676
  case "takeover": {
568
677
  if (roomTakeovers) {
569
678
  // The first agent's time and tokens on the task it handed over.
@@ -591,6 +700,95 @@ export function buildStory(run, runs = 1) {
591
700
  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 });
592
701
  break;
593
702
  }
703
+ case "checkpoint": {
704
+ // A buddy check: another agent read the author's work in progress.
705
+ const a = pick(p, "agent") || e.agent;
706
+ const author = pick(p, "author");
707
+ const notes = pick(p, "notes");
708
+ const ok = !notes || /^LOOKS GOOD/i.test(notes.trim());
709
+ const late = !ok && !!p.late;
710
+ collab.buddy.push({ agent: a ?? "", author, pct: Number(p.pct) || 0, notes: ok ? "" : notes, ...(late ? { late } : {}) });
711
+ 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) };
712
+ add(it);
713
+ if (!ok) {
714
+ talked(a, author);
715
+ thread(it);
716
+ }
717
+ break;
718
+ }
719
+ // A note that needed an answer, settled when its task finished (src/room/followup.ts).
720
+ case "note.closed": {
721
+ const to = pick(p, "to") || e.agent || "";
722
+ const by = pick(p, "by");
723
+ const how = pick(p, "how") || "ignored";
724
+ const kind = pick(p, "kind") || "buddy";
725
+ collab.followups.push({ to, by, kind, how, note: pick(p, "body"), ...(title(e.task) ? { task: title(e.task) } : {}) });
726
+ const what = kind === "radar" ? "the radar's finding" : `${by}'s buddy note`;
727
+ const verb = how === "replied" ? "answers" : "addresses (in code)";
728
+ add({
729
+ ts,
730
+ kind: how === "ignored" ? "fail" : "review",
731
+ type: e.type,
732
+ agent: to || null,
733
+ to: kind === "radar" ? null : by || null,
734
+ 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))}`,
735
+ detail: oneLine(pick(p, "body"), 300) || undefined,
736
+ });
737
+ break;
738
+ }
739
+ case "followup": // the scheduler's copy of note.closed
740
+ case "note.open": // the note itself is an agent.message; run.checkpoint / run.radar tell it
741
+ case "buddy.start":
742
+ case "buddy.end":
743
+ break;
744
+ case "done.refused": {
745
+ const a = e.agent;
746
+ 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` });
747
+ break;
748
+ }
749
+ case "pair.dropped": {
750
+ const w = pick(p, "with");
751
+ 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 });
752
+ break;
753
+ }
754
+ case "pair": {
755
+ const a = pick(p, "agent") || e.agent;
756
+ const w = pick(p, "with");
757
+ collab.pairs.push({ agent: a ?? "", with: w, part: pick(p, "part") });
758
+ 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 });
759
+ talked(a, w);
760
+ break;
761
+ }
762
+ case "council.adopt":
763
+ case "council.flaw": {
764
+ // Cross-review: what one agent took from (or found wrong in) another's proposal.
765
+ const a = pick(p, "agent") || e.agent;
766
+ const of = pick(p, "of");
767
+ 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)}` };
768
+ add(it);
769
+ talked(a, of);
770
+ (e.type === "council.adopt" ? collab.adopts : collab.flaws).push({ by: a ?? "", of, text: pick(p, "body", "text") });
771
+ break;
772
+ }
773
+ case "council.ratify": {
774
+ const a = pick(p, "agent") || e.agent;
775
+ const obj = list(p.objections);
776
+ collab.ratify.push({ agent: a ?? "", verdict: pick(p, "verdict") || (obj.length ? "object" : "ok"), objections: obj });
777
+ 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)" : ""}` });
778
+ break;
779
+ }
780
+ case "contract.set":
781
+ if (pick(p, "text"))
782
+ collab.contract = { by: pick(p, "by") || e.agent || "council", chars: str(p.text).length };
783
+ if (pick(p, "text"))
784
+ 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` });
785
+ break;
786
+ case "contract.amended":
787
+ collab.amendments.push({ by: pick(p, "by") || e.agent || "?", version: Number(p.version) || 0, change: `${pick(p, "section")}: ${pick(p, "change")}` });
788
+ 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 });
789
+ break;
790
+ case "run.contract": // the Room's contract.amended says the same
791
+ break;
594
792
  case "council.skipped": {
595
793
  const a = pick(p, "agent") || e.agent;
596
794
  add({ ts, kind: "design", type: e.type, agent: a, text: `${pick(p, "round") || "council"} skipped (${pick(p, "reason") || "no answer"})` });
@@ -616,7 +814,7 @@ export function buildStory(run, runs = 1) {
616
814
  const a = pick(p, "agent", "by", "checker") || list(p.agents)[0] || e.agent;
617
815
  if (isAgent(a))
618
816
  begin({ agent: a, from: ts, kind: "qa", label: "QA" });
619
- 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 });
817
+ 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 });
620
818
  break;
621
819
  }
622
820
  case "qa.bug": {
@@ -625,10 +823,14 @@ export function buildStory(run, runs = 1) {
625
823
  if (isAgent(a))
626
824
  close(a, ts, "warn");
627
825
  const owner2 = pick(p, "to", "owner", "assigned_to", "author");
628
- add({ ts, kind: "qa", type: e.type, agent: a, to: owner2 || null, text: `🐞 bug: ${oneLine(pick(p, "title", "summary", "bug", "body"), 200)}${owner2 ? ` → ${owner2}` : ""}`, detail: oneLine(pick(p, "details", "repro", "spec", "body"), 300) || undefined });
826
+ collab.bugs.push({ by: a ?? "", to: owner2, title: pick(p, "title", "summary", "bug", "body") });
827
+ 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 });
629
828
  talked(a, owner2);
630
829
  break;
631
830
  }
831
+ case "qa.suspect":
832
+ add({ ts, kind: "qa", type: e.type, agent: null, text: `? QA round ${str(p.round)} clean, but quick · ${str(p.reason)}` });
833
+ break;
632
834
  case "qa.unchecked":
633
835
  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"}` });
634
836
  break;
@@ -643,7 +845,10 @@ export function buildStory(run, runs = 1) {
643
845
  case "qa.fix": {
644
846
  const a = pick(p, "agent", "by", "fixed_by") || e.agent;
645
847
  st(a).bugsFixed++;
646
- add({ ts, kind: "qa", type: e.type, agent: a, text: `🔧 fixed: ${oneLine(pick(p, "title", "summary", "bug", "body") || title(e.task), 200)}` });
848
+ const fixed = collab.bugs.find((b) => !b.fixedBy && b.title === (pick(p, "title", "summary", "bug", "body") || title(e.task)));
849
+ if (fixed && a)
850
+ fixed.fixedBy = a;
851
+ add({ ts, kind: "qa", type: e.type, agent: a, text: `✓ fixed: ${oneLine(pick(p, "title", "summary", "bug", "body") || title(e.task), 200)}` });
647
852
  break;
648
853
  }
649
854
  default: {
@@ -663,28 +868,183 @@ export function buildStory(run, runs = 1) {
663
868
  }
664
869
  for (const a of [...open.keys()])
665
870
  close(a, run.end);
871
+ // A QA bug is fixed when its "Fix: <title>" task merged (the title may be cut on the board).
872
+ for (const b of collab.bugs) {
873
+ const want = oneLine(b.title, 60).replace(/…$/, "");
874
+ const hit = mergedTitles.find((m) => m.title.startsWith("Fix: ") && m.title.slice(5).startsWith(want.slice(0, 40)));
875
+ if (hit)
876
+ b.fixedBy = hit.agent;
877
+ }
666
878
  // Who proposed what: a council plan's tasks may say whose idea each one was.
667
879
  for (const t of councilTasks) {
668
880
  const pt = plan.tasks.find((x) => x.title === pick(t ?? {}, "title"));
669
- const who = list((t ?? {}).proposed_by ?? (t ?? {}).by ?? (t ?? {}).from ?? (t ?? {}).author).join(", ");
881
+ const from = list((t ?? {}).proposed_by ?? (t ?? {}).by ?? (t ?? {}).from ?? (t ?? {}).author);
882
+ const primary = pick(t ?? {}, "primary") || from[0] || "";
883
+ if (primary)
884
+ collab.credit.set(primary, (collab.credit.get(primary) ?? 0) + 1);
885
+ const rest = from.filter((x) => x !== primary);
886
+ const who = primary ? `${primary}${rest.length ? ` (with ${rest.join(", ")})` : ""}` : "";
670
887
  if (pt && who && !pt.by)
671
888
  pt.by = who;
672
889
  }
673
- return { run, runs, agents, plan, design, items, spans, stats, talk, threads, merged, failed };
890
+ return { run, runs, agents, plan, design, items, spans, stats, talk, threads, screen: [...screen.values()], merged, failed, collab };
891
+ }
892
+ // ---- teamwork summary (terminal + HTML) -------------------------------------------
893
+ /** The run's collaboration as labelled lines; [] when nobody worked with anybody. */
894
+ export function collabLines(s) {
895
+ const c = s.collab;
896
+ const out = [];
897
+ const n = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
898
+ for (const a of c.adopts.slice(0, 6))
899
+ out.push({ label: "adopted", text: `${a.by} took ${a.of}'s idea: ${oneLine(a.text, 110)}` });
900
+ if (c.adopts.length > 6)
901
+ out.push({ label: "adopted", text: `…and ${c.adopts.length - 6} more` });
902
+ for (const f of c.flaws.slice(0, 6))
903
+ out.push({ label: "flaw", text: `${f.by} on ${f.of}'s idea: ${oneLine(f.text, 110)}` });
904
+ if (c.flaws.length > 6)
905
+ out.push({ label: "flaw", text: `…and ${c.flaws.length - 6} more` });
906
+ if (c.synthesizer)
907
+ out.push({ label: "plan by", text: `${c.synthesizer.id}${c.synthesizer.why ? ` — ${oneLine(c.synthesizer.why, 110)}` : ""}` });
908
+ if (c.ratify.length) {
909
+ const ok = c.ratify.filter((r) => r.verdict !== "object").map((r) => r.agent);
910
+ const obj = c.ratify.filter((r) => r.verdict === "object");
911
+ out.push({ label: "ratify", text: [ok.length ? `${ok.join(", ")} ok` : "", ...obj.map((r) => `${r.agent} objects: ${oneLine(r.objections.join(" · "), 90)}`)].filter(Boolean).join(" · ") });
912
+ }
913
+ for (const o of c.objections.slice(0, 4))
914
+ out.push({ label: "revision", text: `${o.taken ? "took" : "kept the plan over"} ${o.by}'s objection: ${oneLine(o.objection, 100)}` });
915
+ if (c.contract || c.amendments.length) {
916
+ const v = c.amendments.length ? Math.max(...c.amendments.map((a) => a.version)) : 1;
917
+ out.push({ label: "contract", text: `v${v}${c.contract ? ` · written by ${c.contract.by}` : " · started mid-run"} · ${n(c.amendments.length, "amendment")}` });
918
+ for (const a of c.amendments.slice(-4))
919
+ out.push({ label: "", text: `v${a.version} ${a.by}: ${oneLine(a.change, 110)}` });
920
+ }
921
+ if (!c.radar.length && c.radarLooks?.looks)
922
+ out.push({ label: "radar", text: `no catches · compared 2+ agents' work in progress ${n(c.radarLooks.looks, "time")} (${n(c.radarLooks.pairs, "pair")})` });
923
+ if (c.radar.length) {
924
+ out.push({ label: "radar", text: `${n(c.radar.length, "catch", "catches")} while agents worked` });
925
+ for (const r of c.radar.slice(0, 4))
926
+ out.push({ label: "", text: `${r.kind === "overlap" ? "overlap" : "mismatch"} ${r.agent} ↔ ${r.with}: ${oneLine(r.detail, 100)}` });
927
+ }
928
+ if (c.buddy.length) {
929
+ const sent = c.buddy.filter((b) => b.notes);
930
+ const late = sent.filter((b) => b.late).length;
931
+ 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)` : ""}` });
932
+ for (const b of sent.slice(0, 4))
933
+ out.push({ label: "", text: `${b.agent} → ${b.author} at ${b.pct}%: ${oneLine(b.notes, 100)}` });
934
+ }
935
+ if (c.followups.length) {
936
+ const k = (h) => c.followups.filter((f) => f.how === h).length;
937
+ const ignored = c.followups.filter((f) => f.how === "ignored");
938
+ out.push({ label: "answered", text: `${n(c.followups.length - ignored.length, "note")} addressed (${k("fixed")} fixed, ${k("replied")} replied) · ${ignored.length} ignored` });
939
+ for (const f of ignored.slice(0, 4))
940
+ out.push({ label: "", text: `ignored by ${f.to}: ${f.kind === "radar" ? "radar" : f.by}: ${oneLine(f.note.replace(/^buddy check from [^:]*:\s*/i, ""), 100)}` });
941
+ }
942
+ for (const p of c.pairs.slice(0, 4))
943
+ out.push({ label: "pair", text: `${p.agent} helped ${p.with}: ${oneLine(p.part, 110)}` });
944
+ if (c.credit.size && (c.adopts.length || c.credit.size > 1))
945
+ out.push({ label: "credit", text: [...c.credit.entries()].sort((a, b) => b[1] - a[1]).map(([a, k]) => `${a} ${n(k, "task")}`).join(" · ") });
946
+ return out;
947
+ }
948
+ export function crewRows(s) {
949
+ const done = s.items.filter((it) => it.type === "qa.done");
950
+ return s.agents.map((a) => {
951
+ const x = s.stats.get(a);
952
+ const qa = done.length ? done.filter((it) => it.agent === a).length : s.items.filter((it) => it.type === "qa.round" && it.agent === a).length;
953
+ return { agent: a, merged: x.merged, failed: x.failed, reviews: x.reviews, qa, ms: x.ms, tokens: x.tokens, cached: x.cached };
954
+ });
955
+ }
956
+ export const plural = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
957
+ /** "5 merged · 3 reviews · 1 QA" — what an agent did, nothing for what it didn't. */
958
+ export function didLine(c) {
959
+ const parts = [`${c.merged} merged`, c.failed ? `${c.failed} failed` : "", c.reviews ? plural(c.reviews, "review") : "", c.qa ? `${c.qa} QA` : ""];
960
+ return parts.filter(Boolean).join(" · ");
961
+ }
962
+ /** The run cut into its phases: the build, then QA (from the first QA round on). */
963
+ export function phases(s) {
964
+ // The council's decisions and risks, written to shared memory as the build starts: part of the plan.
965
+ const agreed = s.items.filter((it) => it.agent === "council" && it.type === "memory.added");
966
+ const rest = s.items.filter((it) => !agreed.includes(it));
967
+ const i = rest.findIndex((it) => it.type === "qa.round");
968
+ return i < 0 ? { agreed, build: rest, qa: [] } : { agreed, build: rest.slice(0, i), qa: rest.slice(i) };
969
+ }
970
+ /** A council decision or risk from shared memory, as "decided" / "risk (by)". */
971
+ export function agreement(it) {
972
+ const risk = /^gotcha: council risk:/.test(it.text);
973
+ return { risk, by: risk ? /flagged by (\S+)/.exec(it.detail ?? "")?.[1] : undefined, body: it.text.replace(/^\w+: council( risk)?:\s*/, "") };
974
+ }
975
+ /** The council is recorded in one go when it's done: its items share one instant, so they get no clock. */
976
+ const councilInstant = (s) => s.design.length > 1 && s.design.at(-1).ts - s.design[0].ts < 2000;
977
+ /** How the run ended: totals, merges gated on tests, QA's last word. */
978
+ export function outcome(s) {
979
+ const rows = crewRows(s);
980
+ const tokens = rows.reduce((n, c) => n + c.tokens, 0);
981
+ const cached = rows.reduce((n, c) => n + c.cached, 0);
982
+ const merges = s.items.filter((it) => it.type === "run.merged");
983
+ const green = merges.filter((it) => /tests? (green|pass)/i.test(it.detail ?? "")).length;
984
+ const last = [...s.items].reverse().find((it) => ["qa.clean", "qa.capped", "qa.unchecked"].includes(it.type));
985
+ const qa = last ? { text: last.text.replace(/^[✓!]\s*/, ""), tone: last.type === "qa.clean" ? "ok" : "warn" } : null;
986
+ const rounds = s.items.filter((it) => it.type === "qa.round").length;
987
+ return { tokens, cached, merges: merges.length, green, qa, rounds, ms: s.run.end - s.run.start };
988
+ }
989
+ /** The teamwork as numbers, for the summary line and the HTML tiles. Only what happened (or was looked for). */
990
+ export function tally(s) {
991
+ const c = s.collab;
992
+ const out = [];
993
+ if (c.adopts.length || c.flaws.length) {
994
+ out.push({ n: String(c.adopts.length), label: c.adopts.length === 1 ? "idea adopted" : "ideas adopted", tone: "ok" });
995
+ out.push({ n: String(c.flaws.length), label: c.flaws.length === 1 ? "flaw flagged" : "flaws flagged" });
996
+ }
997
+ if (c.objections.length)
998
+ out.push({ n: `${c.objections.filter((o) => o.taken).length}/${c.objections.length}`, label: "objections taken", tone: "ok" });
999
+ if (c.contract || c.amendments.length) {
1000
+ const v = c.amendments.length ? Math.max(...c.amendments.map((a) => a.version)) : 1;
1001
+ out.push({ n: `v${v}`, label: `contract, ${plural(c.amendments.length, "amendment")}`, text: `contract v${v} (${plural(c.amendments.length, "amendment")})` });
1002
+ }
1003
+ if (c.buddy.length)
1004
+ out.push({ n: String(c.buddy.length), label: `buddy checks, ${plural(c.buddy.filter((b) => b.notes).length, "note")}` });
1005
+ if (c.followups.length) {
1006
+ const ignored = c.followups.filter((f) => f.how === "ignored").length;
1007
+ out.push({ n: `${c.followups.length - ignored}/${c.followups.length}`, label: "notes answered", tone: ignored ? "warn" : "ok" });
1008
+ }
1009
+ if (c.radar.length || c.radarLooks?.looks)
1010
+ out.push({ n: String(c.radar.length), label: c.radar.length === 1 ? "radar catch" : "radar catches", tone: c.radar.length ? "warn" : undefined });
1011
+ if (c.pairs.length)
1012
+ out.push({ n: String(c.pairs.length), label: c.pairs.length === 1 ? "pair" : "pairs" });
1013
+ if (c.bugs.length) {
1014
+ const fixed = c.bugs.filter((b) => b.fixedBy).length;
1015
+ 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` });
1016
+ }
1017
+ const msgs = [...s.stats.values()].reduce((n, x) => n + x.sent, 0);
1018
+ if (msgs)
1019
+ out.push({ n: String(msgs), label: msgs === 1 ? "message between agents" : "messages between agents" });
1020
+ return out;
1021
+ }
1022
+ /** Each buddy note with how it ended: fixed in code, answered, ignored — or just sent. */
1023
+ export function buddyNotes(s) {
1024
+ const left = s.collab.followups.filter((f) => f.kind !== "radar");
1025
+ return s.collab.buddy
1026
+ .filter((b) => b.notes)
1027
+ .map((b) => {
1028
+ const i = left.findIndex((f) => f.by === b.agent && f.to === b.author);
1029
+ const f = i >= 0 ? left.splice(i, 1)[0] : undefined;
1030
+ const how = f ? (f.how === "ignored" ? "ignored" : f.how === "replied" ? "answered" : "fixed in code") : b.late ? "came back late" : "sent";
1031
+ const tone = f ? (f.how === "ignored" ? "bad" : "ok") : b.late ? "warn" : "";
1032
+ return { ...b, how, tone };
1033
+ });
674
1034
  }
675
1035
  // ---- terminal -----------------------------------------------------------------
676
- const clock = (ms) => {
1036
+ export const clock = (ms) => {
677
1037
  const s = Math.max(0, Math.round(ms / 1000));
678
1038
  return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
679
1039
  };
680
1040
  const kindMark = {
681
- design: C.violet,
1041
+ design: (s) => s,
682
1042
  plan: C.violet,
683
1043
  task: (s) => s,
684
1044
  progress: C.faint,
685
1045
  announce: C.faint,
686
1046
  message: C.bold,
687
- memory: C.violet,
1047
+ memory: C.faint,
688
1048
  handoff: C.violet,
689
1049
  review: C.violet,
690
1050
  merge: C.green,
@@ -695,24 +1055,83 @@ const kindMark = {
695
1055
  team: C.faint,
696
1056
  other: C.faint,
697
1057
  };
698
- export function renderText(s) {
1058
+ /** Parts joined with " · ", wrapped onto as many lines as `width` needs. */
1059
+ function wrapParts(parts, width, indent = " ") {
1060
+ const out = [];
1061
+ let cur = "";
1062
+ for (const p of parts) {
1063
+ const next = cur ? `${cur} · ${p}` : p;
1064
+ if (cur && indent.length + next.length > width) {
1065
+ out.push(indent + cur);
1066
+ cur = p;
1067
+ }
1068
+ else
1069
+ cur = next;
1070
+ }
1071
+ if (cur)
1072
+ out.push(indent + cur);
1073
+ return out;
1074
+ }
1075
+ /**
1076
+ * The story in the terminal, in the live run's words: COUNCIL → PLAN → BUILD →
1077
+ * QA → RESULT. `width` (the terminal's columns) cuts every line to fit; without
1078
+ * it (a file, a pipe) lines are kept whole.
1079
+ */
1080
+ export function renderText(s, opts = {}) {
699
1081
  const out = [];
700
1082
  const r = s.run;
1083
+ const W = opts.width ?? 120;
1084
+ const nameW = Math.max(6, ...s.agents.map((a) => a.length));
701
1085
  const who = (a) => (a ? `${dot(a)} ${C.bold(a)}` : `${C.violet("◆")} ${C.faint("huddle")}`);
1086
+ const whoPad = (a) => (a ? `${dot(a)} ${C.bold(pad(a, nameW))}` : `${C.violet("◆")} ${C.faint(pad("huddle", nameW))}`);
1087
+ const indent = " ".repeat(2 + 6 + 1 + 2 + nameW + 1);
1088
+ const instant = councilInstant(s);
702
1089
  const line = (it) => {
703
- const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : who(it.agent);
1090
+ const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : whoPad(it.agent);
704
1091
  const tag = it.kind === "message" && it.detail ? C.faint(` (${it.detail})`) : "";
705
- out.push(` ${C.faint(pad(clock(it.ts - r.start), 7))} ${head} ${kindMark[it.kind](it.text)}${tag}`);
706
- if (it.detail && it.kind !== "message")
1092
+ const t = it.kind === "design" && instant ? "" : clock(it.ts - r.start);
1093
+ out.push(` ${C.faint(pad(t, 6))} ${head} ${kindMark[it.kind](it.text)}${tag}`);
1094
+ // Details that only repeat the task's title or a why, and specs of new tasks, stay in the HTML.
1095
+ const quiet = it.kind === "message" || it.kind === "progress" || it.type === "memory.added" || it.type === "task.added";
1096
+ if (it.detail && !quiet)
707
1097
  for (const l of it.detail.split("\n").slice(0, 6))
708
- out.push(C.faint(` ${l}`));
1098
+ out.push(C.faint(`${indent}${l}`));
709
1099
  };
1100
+ const head = (name, sub = "") => out.push("", `${C.violet(name)}${sub ? C.faint(" · " + sub) : ""}`);
1101
+ const o = outcome(s);
1102
+ const crew = crewRows(s);
710
1103
  out.push(`${C.violet("STORY")} ${C.bold(JSON.stringify(goalTitle(r.goal)))}`);
711
- out.push(C.faint(` run ${r.index} of ${s.runs}${r.id ? " · " + r.id : ""} · ${new Date(r.start).toLocaleString()} · ${fmtDuration(r.end - r.start)} · ${s.merged} merged, ${s.failed} failed`));
712
- if (s.agents.length)
713
- out.push(` ${C.faint("crew:")} ${s.agents.map((a) => `${dot(a)} ${a}`).join(" ")}`);
1104
+ 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")}`));
1105
+ const mark = s.failed ? C.yellow("◎") : C.green("✓");
1106
+ out.push(` ${mark} ${s.merged} merged, ${s.failed} failed` +
1107
+ C.faint(`${o.merges ? ` · ${o.green}/${o.merges} merged on green tests` : ""}${o.tokens ? ` · ${fmtTokens(o.tokens)} tok` : ""}`));
1108
+ if (o.qa)
1109
+ out.push(` ${o.qa.tone === "ok" ? C.green("✓") : C.yellow("!")} ${o.qa.text}`);
1110
+ for (const c of crew)
1111
+ out.push(` ${dot(c.agent)} ${pad(c.agent, nameW)} ${didLine(c)}${C.faint(`${c.ms ? " · " + fmtDuration(c.ms) : ""}${c.tokens ? " · " + fmtTokens(c.tokens) + " tok" : ""}`)}`);
1112
+ const team = collabLines(s);
1113
+ const nums = tally(s);
1114
+ if (team.length || nums.length) {
1115
+ head("TEAMWORK", "who built on whom");
1116
+ out.push(...wrapParts(nums.map((t) => t.text ?? `${t.n} ${t.label}`), W).map((l) => C.bold(l)));
1117
+ // The terminal shows the first few ideas and flaws; the HTML has them all.
1118
+ 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);
1119
+ const shown = new Map();
1120
+ for (const l of team) {
1121
+ const capped = l.label === "adopted" || l.label === "flaw";
1122
+ const k = (shown.get(l.label) ?? 0) + 1;
1123
+ shown.set(l.label, k);
1124
+ if (capped && k > 3) {
1125
+ if (k === 4)
1126
+ out.push(` ${C.faint(pad(l.label, 9))} ${C.faint(`…and ${total(l.label) - 3} more`)}`);
1127
+ continue;
1128
+ }
1129
+ out.push(` ${C.faint(pad(l.label, 9))} ${l.text}`);
1130
+ }
1131
+ }
714
1132
  if (s.design.length) {
715
- out.push("", C.violet("DESIGN COUNCIL"));
1133
+ const members = [...new Set(s.design.map((it) => it.agent).filter(Boolean))];
1134
+ head("COUNCIL", `${members.length ? members.join(", ") + " " : ""}designed it before anyone coded`);
716
1135
  s.design.forEach(line);
717
1136
  }
718
1137
  if (s.plan.tasks.length) {
@@ -721,21 +1140,60 @@ export function renderText(s) {
721
1140
  s.plan.tasks.forEach((t, i) => {
722
1141
  const deps = t.deps.map((d) => "#" + (num.get(d) ?? d));
723
1142
  out.push(` ${C.faint(String(i + 1).padStart(2) + ".")} ${t.title}` +
724
- 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 ? `proposed by ${t.by}` : ""]
1143
+ 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}` : ""]
725
1144
  .filter(Boolean)
726
1145
  .map((x) => " · " + x)
727
1146
  .join("")));
728
1147
  });
1148
+ const c = s.collab;
1149
+ for (const it of phases(s).agreed) {
1150
+ const a = agreement(it);
1151
+ out.push(a.risk ? ` ${C.yellow("!")} ${C.faint(`risk${a.by ? ` (${a.by})` : ""}:`)} ${a.body}` : ` ${C.violet("◆")} ${C.faint("decided:")} ${a.body}`);
1152
+ }
1153
+ if (c.contract) {
1154
+ const taken = c.objections.filter((x) => x.taken).length;
1155
+ 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`)}`);
1156
+ }
1157
+ }
1158
+ if (s.screen.length) {
1159
+ head("SHARED SCREEN", "one copy every agent reads");
1160
+ for (const x of s.screen) {
1161
+ 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(""))}`);
1162
+ out.push(C.faint(` read by: ${x.readers.length ? x.readers.join(", ") : "nobody"}`));
1163
+ for (const n of x.notes)
1164
+ out.push(` 📌 ${C.bold(n.by)} ${oneLine(n.body, 160)}`);
1165
+ }
729
1166
  }
730
- out.push("", C.violet("TIMELINE"));
1167
+ const ph = phases(s);
1168
+ head("BUILD", "agents code, check each other, merge");
731
1169
  if (!s.items.length)
732
1170
  out.push(C.faint(" (nothing happened in this run)"));
733
- s.items.forEach(line);
1171
+ ph.build.forEach(line);
1172
+ if (ph.qa.length) {
1173
+ head("QA", "agents re-check the merged build; bugs become fix tasks");
1174
+ ph.qa.forEach(line);
1175
+ }
1176
+ head("RESULT");
1177
+ 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)` : ""}`)}`);
1178
+ if (o.merges)
1179
+ out.push(` ${o.green === o.merges ? C.green("✓") : C.yellow("!")} ${o.green}/${o.merges} merges passed the tests first`);
1180
+ if (o.qa)
1181
+ out.push(` ${o.qa.tone === "ok" ? C.green("✓") : C.yellow("!")} ${plural(o.rounds, "QA round")} · ${o.qa.text}`);
734
1182
  if (s.agents.length) {
735
- out.push("", C.violet("WHO DID WHAT"));
736
- const cols = ["merged", "failed", "reviews", "msgs out/in", "design", "bugs found/fixed", "takeovers in/out", "tool calls", "time", "tokens"];
737
- const w = Math.max(10, ...s.agents.map((a) => a.length + 4)); // "● name" + a gap
738
- out.push(C.faint(" " + pad("", w) + cols.map((c) => pad(c, Math.max(c.length, 6) + 2)).join("")));
1183
+ out.push("", `${C.violet("WHO DID WHAT")} ${C.faint("· msgs sent/got · bugs found/fixed · takeovers in/out")}`);
1184
+ const cols = [
1185
+ ["merged", 7],
1186
+ ["failed", 7],
1187
+ ["reviews", 8],
1188
+ ["msgs", 7],
1189
+ ["bugs", 6],
1190
+ ["takeover", 9],
1191
+ ["tools", 6],
1192
+ ["time", 8],
1193
+ ["tokens", 7],
1194
+ ];
1195
+ const w = nameW + 4; // "● name" + a gap
1196
+ out.push(C.faint(" " + pad("", w) + cols.map(([c, n]) => pad(c, n)).join("")));
739
1197
  for (const a of s.agents) {
740
1198
  const x = s.stats.get(a);
741
1199
  const vals = [
@@ -743,16 +1201,15 @@ export function renderText(s) {
743
1201
  x.failed,
744
1202
  x.reviews + (x.changesAsked ? ` (${x.changesAsked}✎)` : ""),
745
1203
  `${x.sent}/${x.received}`,
746
- x.proposals || x.critiques ? `${x.proposals}p ${x.critiques}c` : "0",
747
1204
  `${x.bugsFound}/${x.bugsFixed}`,
748
1205
  `${x.takeoversIn}/${x.takeoversOut}`,
749
1206
  x.toolCalls,
750
1207
  x.ms ? fmtDuration(x.ms) : "—",
751
1208
  x.tokens ? fmtTokens(x.tokens) : "—",
752
1209
  ].map(String);
753
- out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, Math.max(cols[i].length, 6) + 2)).join("")}`);
1210
+ out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, cols[i][1])).join("")}`);
754
1211
  }
755
- out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("(row → column: messages, questions, answers, reviews, critiques, handoffs)")}`);
1212
+ out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("· row → column: messages, reviews, critiques, handoffs")}`);
756
1213
  const cw = Math.max(8, ...s.agents.map((a) => a.length + 2));
757
1214
  out.push(" " + pad("", w) + s.agents.map((a) => pad(a, cw)).join(""));
758
1215
  for (const a of s.agents) {
@@ -760,7 +1217,7 @@ export function renderText(s) {
760
1217
  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("")}`);
761
1218
  }
762
1219
  }
763
- return out.join("\n");
1220
+ return (opts.width ? out.map((l) => truncate(l, Math.max(20, opts.width - 1))) : out).join("\n");
764
1221
  }
765
1222
  export function renderRunList(runs) {
766
1223
  if (!runs.length)
@@ -774,14 +1231,175 @@ export function renderRunList(runs) {
774
1231
  .join("\n");
775
1232
  }
776
1233
  // ---- HTML (a static, self-contained report file) --------------------------------
777
- const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
778
- const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
1234
+ export const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1235
+ export const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
1236
+ /** Lines that are the agents thinking out loud, hidden behind a toggle so the story reads first. */
1237
+ const NOISE = (it) => it.kind === "progress" || it.kind === "announce" || it.kind === "memory" || it.type === "memory.added";
1238
+ /** The story page's look — shared with `huddle export --format html` (src/ui/export.ts). */
1239
+ export const STORY_CSS = `:root{--bg:#f6f5f1;--fg:#1d1b18;--mute:#6f6a62;--line:#e4e0d8;--card:#fff;--soft:#efede7;--ok:#23864c;--bad:#c93f30;--warn:#a86f05;--brand:#6a5af0;color-scheme:light}
1240
+ @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}}
1241
+ :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}
1242
+ *{box-sizing:border-box}
1243
+ 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%}
1244
+ main{max-width:1100px;margin:0 auto;padding:24px 16px 60px}
1245
+ h1{font-size:26px;line-height:1.25;margin:6px 0 4px;word-break:break-word}
1246
+ h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--brand);margin:36px 0 12px}
1247
+ h2 small{text-transform:none;letter-spacing:0}
1248
+ h3{font-size:14px;margin:0 0 10px}
1249
+ .brand{font-weight:700;color:var(--brand);font-size:13px;letter-spacing:.04em}
1250
+ .sub,small,.detail,time,.meta,.none,.legend{color:var(--mute)}small{font-size:12px;margin-left:6px;font-weight:400}
1251
+ .none{font-size:13px;margin:6px 0}
1252
+ .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}
1253
+ .hero{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}
1254
+ .hero details{margin:8px 0}
1255
+ .kpis,.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-top:16px}
1256
+ .kpi,.tile{background:var(--soft);border-radius:12px;padding:10px 12px;min-width:0}
1257
+ .kpi b,.tile b{display:block;font-size:24px;line-height:1.2;font-variant-numeric:tabular-nums}
1258
+ .kpi span,.tile span{font-size:12px;color:var(--mute)}
1259
+ .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)}
1260
+ .verdict{margin:14px 0 0;font-weight:600}.verdict.ok{color:var(--ok)}.verdict.warn{color:var(--warn)}
1261
+ .crew{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;margin-top:12px}
1262
+ .agent{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--c);border-radius:12px;padding:10px 14px;min-width:0}
1263
+ .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)}
1264
+ .agent .as{font-size:14px}.agent .am{font-size:12px;color:var(--mute);font-variant-numeric:tabular-nums}
1265
+ .cards{columns:320px 3;column-gap:12px;margin-top:12px}.cards .card{break-inside:avoid;margin-bottom:12px}
1266
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;min-width:0}
1267
+ .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}
1268
+ .list li.mute{color:var(--mute)}
1269
+ .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}
1270
+ .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)}
1271
+ .grid td,.grid th{text-align:center}.grid th:first-child{text-align:left}
1272
+ .ad,.fl{display:inline-block;min-width:22px;padding:1px 6px;margin:1px;border-radius:6px;font-weight:700;font-size:12px}
1273
+ .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)}
1274
+ .legend{font-size:12px;margin:8px 0}
1275
+ .quote{font-size:13px;padding:6px 0 6px 10px;border-left:3px solid var(--line);margin-top:6px;word-break:break-word}
1276
+ .scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
1277
+ .lanesbox{border:1px solid var(--line);border-radius:12px;background:var(--card)}
1278
+ .lanes{min-width:640px;padding:8px 12px 12px}
1279
+ .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%)}
1280
+ .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
1281
+ .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
1282
+ .track{position:relative;flex:1;height:26px}
1283
+ .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}
1284
+ .bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
1285
+ .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)}
1286
+ .mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
1287
+ .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}
1288
+ ol,ul{padding:0;margin:0;list-style:none}
1289
+ .phase{margin-top:22px}
1290
+ .phase h3{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-weight:400;color:var(--mute);font-size:13px}
1291
+ .tag{font-weight:800;letter-spacing:.08em;color:#fff;background:var(--brand);border-radius:6px;padding:2px 8px;font-size:12px}
1292
+ .timeline li{display:flex;gap:10px;padding:6px 0;font-size:14px;border-top:1px solid var(--line);min-width:0}
1293
+ .timeline li>div{min-width:0;word-break:break-word}
1294
+ .timeline time{flex:none;width:46px;font-variant-numeric:tabular-nums;font-size:13px}
1295
+ .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)}
1296
+ .timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text,.timeline .memory .text{color:var(--mute)}
1297
+ #noise{position:absolute;opacity:0;pointer-events:none}
1298
+ .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}
1299
+ .toggle::before{content:"○ "}#noise:checked~.toggle::before{content:"● "}
1300
+ #noise:focus-visible~.toggle{outline:2px solid var(--brand)}
1301
+ .timeline li.noise{display:none}#noise:checked~.phases .timeline li.noise{display:flex}
1302
+ .arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
1303
+ .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)}
1304
+ .contract{margin:10px 0 0}
1305
+ .result li{font-size:15px}.result li.ok{color:var(--ok)}.result li.warn{color:var(--warn)}
1306
+ details{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
1307
+ .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
1308
+ .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}
1309
+ .thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
1310
+ 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}
1311
+ table.team td{white-space:normal;word-break:break-word}table.team th{color:var(--mute);font-weight:600;vertical-align:top}
1312
+ td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
1313
+ footer{margin-top:40px;font-size:12px;color:var(--mute);text-align:center}
1314
+ @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))}}
1315
+ `;
779
1316
  export function renderHtml(s) {
780
1317
  const r = s.run;
1318
+ const c = s.collab;
1319
+ const o = outcome(s);
781
1320
  const span = Math.max(1, r.end - r.start);
782
1321
  const pct = (ts) => (((ts - r.start) / span) * 100).toFixed(3);
783
1322
  const chip = (a) => `<span class="who"><i style="background:${colorOf(a)}"></i>${esc(a ?? "huddle")}</span>`;
784
- // Lanes: one per agent; spans are its tasks/reviews/conflicts/QA; dots are its messages.
1323
+ const badge = (text, tone = "") => `<span class="badge ${tone}">${esc(text)}</span>`;
1324
+ const card = (title, body, sub = "") => `<div class="card"><h3>${esc(title)}${sub ? ` <small>${esc(sub)}</small>` : ""}</h3>${body}</div>`;
1325
+ const empty = (t) => `<p class="none">${esc(t)}</p>`;
1326
+ // ---- header + crew
1327
+ const kpis = [
1328
+ { n: String(s.merged), l: "merged", tone: "ok" },
1329
+ { n: String(s.failed), l: "failed", tone: s.failed ? "bad" : "" },
1330
+ ...(o.merges ? [{ n: `${o.green}/${o.merges}`, l: "merged on green tests", tone: o.green === o.merges ? "ok" : "warn" }] : []),
1331
+ { n: fmtDuration(o.ms), l: "start to finish", tone: "" },
1332
+ ...(o.tokens ? [{ n: fmtTokens(o.tokens), l: "tokens", tone: "" }] : []),
1333
+ ...(c.bugs.length ? [{ n: `${c.bugs.length}→${c.bugs.filter((b) => b.fixedBy).length}`, l: "QA bugs → fixed", tone: "" }] : []),
1334
+ ]
1335
+ .map((k) => `<div class="kpi ${k.tone}"><b>${esc(k.n)}</b><span>${esc(k.l)}</span></div>`)
1336
+ .join("");
1337
+ const crew = crewRows(s)
1338
+ .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>`)
1339
+ .join("");
1340
+ // ---- teamwork
1341
+ const tiles = tally(s)
1342
+ .map((t) => `<div class="tile ${t.tone ?? ""}"><b>${esc(t.n)}</b><span>${esc(t.label)}</span></div>`)
1343
+ .join("");
1344
+ const ideaAgents = s.agents.filter((a) => [...c.adopts, ...c.flaws].some((x) => x.by === a || x.of === a));
1345
+ const cell = (by, of) => {
1346
+ if (by === of)
1347
+ return `<td class="self">·</td>`;
1348
+ const ad = c.adopts.filter((x) => x.by === by && x.of === of).length;
1349
+ const fl = c.flaws.filter((x) => x.by === by && x.of === of).length;
1350
+ 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>`;
1351
+ };
1352
+ const ideas = ideaAgents.length
1353
+ ? `<div class="scroll"><table class="grid"><tr><th><small>took ↓ · from →</small></th>${ideaAgents.map((a) => `<th>${chip(a)}</th>`).join("")}</tr>${ideaAgents
1354
+ .map((by) => `<tr><th>${chip(by)}</th>${ideaAgents.map((of) => cell(by, of)).join("")}</tr>`)
1355
+ .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
1356
+ .slice(0, 3)
1357
+ .map((a) => `<div class="quote">${chip(a.by)} took ${chip(a.of)}'s idea: ${esc(oneLine(a.text, 160))}</div>`)
1358
+ .join("")}`
1359
+ : "";
1360
+ const contract = [
1361
+ c.synthesizer ? `<li>${chip(c.synthesizer.id)} wrote the plan${c.synthesizer.why ? ` <small>${esc(oneLine(c.synthesizer.why, 120))}</small>` : ""}</li>` : "",
1362
+ // Each objection is listed with whether it was taken; the ratify rows add only who signed off.
1363
+ ...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>`),
1364
+ ...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>`),
1365
+ c.contract ? `<li>◆ contract v1 by ${chip(c.contract.by)} <small>${esc(c.contract.chars)} chars · the names every task codes to</small></li>` : "",
1366
+ ...c.amendments.map((a) => `<li>◆ v${esc(a.version)} by ${chip(a.by)} <span class="detail">${esc(oneLine(a.change, 160))}</span></li>`),
1367
+ ]
1368
+ .filter(Boolean)
1369
+ .join("");
1370
+ const notes = buddyNotes(s);
1371
+ const good = c.buddy.filter((b) => !b.notes).length;
1372
+ const buddy = c.buddy.length
1373
+ ? `<ul class="list">${notes
1374
+ .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>`)
1375
+ .join("")}${good ? `<li class="mute">✓ ${plural(good, "check")} looked good</li>` : ""}</ul>`
1376
+ : "";
1377
+ const bugs = c.bugs.length
1378
+ ? `<ul class="list">${c.bugs
1379
+ .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>`)
1380
+ .join("")}</ul>`
1381
+ : "";
1382
+ const radarFix = c.followups.filter((f) => f.kind === "radar");
1383
+ const radar = [
1384
+ ...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>`),
1385
+ ...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>`),
1386
+ ...c.pairs.map((p) => `<li>⇉ ${chip(p.agent)} paired with ${chip(p.with)}<div class="detail">took: ${esc(oneLine(p.part, 200))}</div></li>`),
1387
+ ].join("");
1388
+ 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>` : "";
1389
+ const team = collabLines(s);
1390
+ const cards = [
1391
+ ideas ? card("Ideas between agents", ideas, "council cross-review") : "",
1392
+ contract ? card("Council → contract", `<ul class="list">${contract}</ul>`) : "",
1393
+ buddy ? card("Buddy notes mid-task", buddy, "and what came of them") : "",
1394
+ bugs ? card("QA bugs → fixes", bugs) : "",
1395
+ radar || radarNote ? card("Radar & pairs", (radar ? `<ul class="list">${radar}</ul>` : "") + radarNote) : "",
1396
+ ]
1397
+ .filter(Boolean)
1398
+ .join("");
1399
+ const teamwork = tiles || cards || team.length
1400
+ ? `<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>` : ""}`
1401
+ : "";
1402
+ // ---- lanes: one per agent; spans are its tasks/reviews/conflicts/QA; dots are its messages.
785
1403
  const ticks = Array.from({ length: 5 }, (_, i) => `<span style="left:${i * 25}%">${esc(clock((span * i) / 4))}</span>`).join("");
786
1404
  const lanes = s.agents
787
1405
  .map((a) => {
@@ -790,19 +1408,50 @@ export function renderHtml(s) {
790
1408
  .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>`)
791
1409
  .join("");
792
1410
  const marks = [...s.items, ...s.design]
793
- .filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail", "announce"].includes(it.kind))
1411
+ .filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail"].includes(it.kind))
794
1412
  .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>`)
795
1413
  .join("");
796
1414
  return `<div class="lane" data-lane="${esc(a)}"><div class="name">${chip(a)}</div><div class="track">${bars}${marks}</div></div>`;
797
1415
  })
798
1416
  .join("");
799
- const row = (it) => `<li class="${it.kind}"><time>${esc(clock(it.ts - r.start))}</time><div>${chip(it.agent)}${it.to ? ` <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>`;
1417
+ // ---- the timeline, by phase
1418
+ const instant = councilInstant(s);
1419
+ 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>`;
800
1420
  const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
801
1421
  const plan = s.plan.tasks
802
- .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>proposed by ${esc(t.by)}</small>` : ""}${t.files.length ? `<div class="detail">${esc(t.files.join(", "))}</div>` : ""}</li>`)
1422
+ .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>`)
803
1423
  .join("");
1424
+ const ph = phases(s);
1425
+ const noise = s.items.filter(NOISE).length;
1426
+ const phase = (tag, sub, body) => `<section class="phase"><h3><span class="tag">${esc(tag)}</span> ${esc(sub)}</h3>${body}</section>`;
1427
+ const members = [...new Set(s.design.map((it) => it.agent).filter(Boolean))];
1428
+ const result = [
1429
+ `<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>`,
1430
+ o.merges ? `<li>${o.green === o.merges ? "✓" : "!"} ${o.green}/${o.merges} merges passed the tests first</li>` : "",
1431
+ o.qa ? `<li class="${o.qa.tone}">${o.qa.tone === "ok" ? "✓" : "!"} ${esc(plural(o.rounds, "QA round"))} · ${esc(o.qa.text)}</li>` : "",
1432
+ ].join("");
1433
+ const timeline = [
1434
+ s.design.length ? phase("COUNCIL", `${members.join(", ")} designed it before anyone coded`, `<ol class="timeline">${s.design.map(row).join("")}</ol>`) : "",
1435
+ s.plan.tasks.length
1436
+ ? phase("PLAN", `${plural(s.plan.tasks.length, "task")}${s.plan.by ? " · by " + s.plan.by : ""}`, `<ol class="plan">${plan}</ol>${ph.agreed.length
1437
+ ? `<ul class="list agreed">${ph.agreed
1438
+ .map((it) => agreement(it))
1439
+ .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>`)
1440
+ .join("")}</ul>`
1441
+ : ""}${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>` : ""}`)
1442
+ : "",
1443
+ 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>`),
1444
+ ph.qa.length ? phase("QA", "agents re-check the merged build; bugs become fix tasks", `<ol class="timeline">${ph.qa.map(row).join("")}</ol>`) : "",
1445
+ phase("RESULT", "", `<ul class="list result">${result}</ul>`),
1446
+ ].join("");
1447
+ // ---- shared screen, conversations, tables
1448
+ const screen = s.screen.length
1449
+ ? `<h2>Shared screen <small>one copy every agent reads</small></h2><ul class="list card">${s.screen
1450
+ .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>`)
1451
+ .join("")}</ul>`
1452
+ : "";
804
1453
  const threads = [...s.threads.entries()]
805
- .map(([pair, msgs]) => `<details open><summary>${esc(pair)} <small>${msgs.length} message${msgs.length > 1 ? "s" : ""}</small></summary><ol class="thread">${msgs
1454
+ .map(([pair, msgs]) => `<details><summary>${esc(pair)} <small>${msgs.length} message${msgs.length > 1 ? "s" : ""}</small></summary><ol class="thread">${msgs
806
1455
  .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>`)
807
1456
  .join("")}</ol></details>`)
808
1457
  .join("");
@@ -822,59 +1471,31 @@ export function renderHtml(s) {
822
1471
  <meta name="viewport" content="width=device-width, initial-scale=1">
823
1472
  <title>Huddle story</title>
824
1473
  <style>
825
- :root{--bg:#faf9f6;--fg:#1d1b18;--mute:#77716a;--line:#e4e0d8;--card:#fff;--ok:#2e9e5b;--bad:#d14b3c;--warn:#c58a12;--brand:#7B6CF6}
826
- @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15141a;--fg:#ece9e3;--mute:#9a948b;--line:#2c2a33;--card:#1d1c23}}
827
- :root[data-theme="dark"]{--bg:#15141a;--fg:#ece9e3;--mute:#9a948b;--line:#2c2a33;--card:#1d1c23}
828
- *{box-sizing:border-box}
829
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
830
- main{max-width:1100px;margin:0 auto;padding:20px 16px 60px}
831
- h1{font-size:22px;margin:0 0 4px}h2{font-size:15px;text-transform:uppercase;letter-spacing:.06em;color:var(--brand);margin:32px 0 10px}
832
- .sub,small,.detail,time,.meta{color:var(--mute)}small{font-size:12px;margin-left:6px}
833
- .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}
834
- .scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--line);border-radius:10px;background:var(--card)}
835
- .lanes{min-width:640px;padding:8px 12px 12px}
836
- .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%)}
837
- .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
838
- .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
839
- .track{position:relative;flex:1;height:26px}
840
- .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}
841
- .bar span{opacity:.9}.bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
842
- .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)}
843
- .mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
844
- .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}.mark.announce{background:var(--mute)}
845
- ol,ul{padding:0;margin:0;list-style:none}
846
- .timeline li{display:flex;gap:10px;padding:7px 0;border-top:1px solid var(--line)}
847
- .timeline time{flex:none;width:48px;font-variant-numeric:tabular-nums;font-size:13px}
848
- .timeline .merge .text{color:var(--ok)}.timeline .fail .text{color:var(--bad)}.timeline .conflict .text,.timeline .qa .text,.timeline .takeover .text{color:var(--warn)}
849
- .timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text{color:var(--mute)}
850
- .arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
851
- .plan li{padding:6px 0;border-top:1px solid var(--line)}.plan{counter-reset:n}.plan li::before{counter-increment:n;content:counter(n) ". ";color:var(--mute)}
852
- details{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
853
- .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
854
- .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}
855
- .thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
856
- 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}
857
- td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
858
- </style>
1474
+ ${STORY_CSS}</style>
859
1475
  </head>
860
1476
  <body>
861
1477
  <main>
862
- <h1>${esc(goalTitle(r.goal))}</h1>${goalTitle(r.goal) !== r.goal.trim() ? `<details><summary>The goal</summary><pre style="white-space:pre-wrap">${esc(r.goal)}</pre></details>` : ""}
863
- <div class="sub">run ${r.index} of ${s.runs}${r.id ? " · " + esc(r.id) : ""} · ${esc(new Date(r.start).toLocaleString())} · ${esc(fmtDuration(r.end - r.start))} · ${s.merged} merged, ${s.failed} failed · ${s.agents.length} agent${s.agents.length === 1 ? "" : "s"}</div>
864
-
865
- <h2>Who was doing what, when</h2>
866
- <div class="scroll"><div class="lanes"><div class="axis">${ticks}</div>${lanes || '<p class="sub">no agents in this run</p>'}</div></div>
867
- <p class="sub"><small>bars: tasks (solid) · reviews (dashed) · conflict resolutions · QA (dotted); right edge green = merged/approved, red = failed/changes, amber = skipped/bug. Dots: messages, design, QA, merges.</small></p>
868
- ${s.design.length ? `<h2>Design council</h2><ol class="timeline">${s.design.map(row).join("")}</ol>` : ""}
869
- ${s.plan.tasks.length ? `<h2>Plan${s.plan.by ? ` <small>by ${esc(s.plan.by)}</small>` : ""}</h2><ol class="plan">${plan}</ol>` : ""}
870
- <h2>Conversations</h2>
871
- ${threads || '<p class="sub">no agent-to-agent messages in this run</p>'}
1478
+ <header class="hero">
1479
+ <div class="brand">⬢ HUDDLE · STORY</div>
1480
+ <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>` : ""}
1481
+ <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>
1482
+ <div class="kpis">${kpis}</div>
1483
+ ${o.qa ? `<p class="verdict ${o.qa.tone}">${o.qa.tone === "ok" ? "✓" : "!"} ${esc(plural(o.rounds, "QA round"))} · ${esc(o.qa.text)}</p>` : ""}
1484
+ </header>
1485
+ ${crew ? `<div class="crew">${crew}</div>` : ""}
1486
+ ${teamwork}
1487
+ <h2>Timeline <small>who was doing what, when</small></h2>
1488
+ <div class="lanesbox scroll"><div class="lanes"><div class="axis">${ticks}</div>${lanes || '<p class="none">no agents in this run</p>'}</div></div>
1489
+ <p class="legend">bars: tasks (solid) · reviews (dashed) · conflict fixes · QA (dotted); right edge green = merged/approved, red = failed/changes, amber = skipped/bug.</p>
1490
+ <input type="checkbox" id="noise"><label class="toggle" for="noise">show progress notes &amp; memory (${noise})</label>
1491
+ <div class="phases">${timeline}</div>
1492
+ ${screen}
1493
+ ${threads ? `<h2>Conversations <small>agent to agent</small></h2>${threads}` : ""}
872
1494
  <h2>Who did what</h2>
873
- <div class="scroll"><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>
1495
+ <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>
874
1496
  <h2>Who talked to whom <small>row → column</small></h2>
875
- <div class="scroll"><table>${matrix}</table></div>
876
- <h2>Timeline</h2>
877
- <ol class="timeline">${s.items.map(row).join("") || '<li class="sub">nothing happened in this run</li>'}</ol>
1497
+ <div class="scroll card"><table>${matrix}</table></div>
1498
+ <footer>made by huddle story · one file, no network</footer>
878
1499
  </main>
879
1500
  </body>
880
1501
  </html>
@@ -927,6 +1548,7 @@ export async function runStory(args) {
927
1548
  console.log(`${C.green("✓")} wrote ${out} ${C.faint(`· one file, no network · ${story.agents.length} lanes, ${story.items.length} events`)}`);
928
1549
  return;
929
1550
  }
930
- console.log(renderText(story));
1551
+ // A terminal gets lines cut to its width; a file or a pipe gets them whole.
1552
+ console.log(renderText(story, { width: process.stdout.isTTY ? process.stdout.columns || 80 : undefined }));
931
1553
  }
932
1554
  //# sourceMappingURL=story.js.map