huddle-cli 0.1.0 → 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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
package/dist/ui/story.js CHANGED
@@ -13,7 +13,7 @@
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). */
@@ -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;
@@ -358,6 +402,18 @@ export function buildStory(run, runs = 1) {
358
402
  // Huddle's automatic help offer, sent in a free agent's name: a board event,
359
403
  // not a message the agent wrote. Found live (capstone round 2): 6 offers
360
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
+ }
361
417
  if (isHelpOffer(from, pick(p, "body"))) {
362
418
  const t = /your task "(.*)" \(/.exec(pick(p, "body"))?.[1];
363
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)` });
@@ -486,6 +542,7 @@ export function buildStory(run, runs = 1) {
486
542
  merged++;
487
543
  if (/^Fix: /.test(title(e.task, p)))
488
544
  s.bugsFixed++; // a QA bug's fix task landed
545
+ mergedTitles.push({ title: title(e.task, p), agent: a });
489
546
  close(a, ts, "ok");
490
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(" · ") });
491
548
  break;
@@ -530,7 +587,11 @@ export function buildStory(run, runs = 1) {
530
587
  const a = pick(p, "agent", "from", "by", "critic") || e.agent;
531
588
  const of = pick(p, "of", "target", "to", "author");
532
589
  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)}` };
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)}` };
534
595
  add(it);
535
596
  talked(a, of);
536
597
  thread(it);
@@ -541,7 +602,19 @@ export function buildStory(run, runs = 1) {
541
602
  plan.by = plan.by ?? (by ? `${by} (council)` : "council");
542
603
  const tasks = Array.isArray(p.tasks) ? p.tasks : [];
543
604
  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) : ""}` });
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
+ });
545
618
  break;
546
619
  }
547
620
  case "task.takeover": {
@@ -564,6 +637,19 @@ export function buildStory(run, runs = 1) {
564
637
  break;
565
638
  case "offer": // the help offer itself is an agent.message in the busy agent's inbox
566
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
+ }
567
653
  case "takeover": {
568
654
  if (roomTakeovers) {
569
655
  // The first agent's time and tokens on the task it handed over.
@@ -591,6 +677,95 @@ export function buildStory(run, runs = 1) {
591
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 });
592
678
  break;
593
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;
594
769
  case "council.skipped": {
595
770
  const a = pick(p, "agent") || e.agent;
596
771
  add({ ts, kind: "design", type: e.type, agent: a, text: `${pick(p, "round") || "council"} skipped (${pick(p, "reason") || "no answer"})` });
@@ -616,7 +791,7 @@ export function buildStory(run, runs = 1) {
616
791
  const a = pick(p, "agent", "by", "checker") || list(p.agents)[0] || e.agent;
617
792
  if (isAgent(a))
618
793
  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 });
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 });
620
795
  break;
621
796
  }
622
797
  case "qa.bug": {
@@ -625,10 +800,14 @@ export function buildStory(run, runs = 1) {
625
800
  if (isAgent(a))
626
801
  close(a, ts, "warn");
627
802
  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 });
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 });
629
805
  talked(a, owner2);
630
806
  break;
631
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;
632
811
  case "qa.unchecked":
633
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"}` });
634
813
  break;
@@ -643,7 +822,10 @@ export function buildStory(run, runs = 1) {
643
822
  case "qa.fix": {
644
823
  const a = pick(p, "agent", "by", "fixed_by") || e.agent;
645
824
  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)}` });
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)}` });
647
829
  break;
648
830
  }
649
831
  default: {
@@ -663,14 +845,169 @@ export function buildStory(run, runs = 1) {
663
845
  }
664
846
  for (const a of [...open.keys()])
665
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
+ }
666
855
  // Who proposed what: a council plan's tasks may say whose idea each one was.
667
856
  for (const t of councilTasks) {
668
857
  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(", ");
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(", ")})` : ""}` : "";
670
864
  if (pt && who && !pt.by)
671
865
  pt.by = who;
672
866
  }
673
- return { run, runs, agents, plan, design, items, spans, stats, talk, threads, merged, failed };
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
+ });
674
1011
  }
675
1012
  // ---- terminal -----------------------------------------------------------------
676
1013
  const clock = (ms) => {
@@ -678,13 +1015,13 @@ const clock = (ms) => {
678
1015
  return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
679
1016
  };
680
1017
  const kindMark = {
681
- design: C.violet,
1018
+ design: (s) => s,
682
1019
  plan: C.violet,
683
1020
  task: (s) => s,
684
1021
  progress: C.faint,
685
1022
  announce: C.faint,
686
1023
  message: C.bold,
687
- memory: C.violet,
1024
+ memory: C.faint,
688
1025
  handoff: C.violet,
689
1026
  review: C.violet,
690
1027
  merge: C.green,
@@ -695,24 +1032,83 @@ const kindMark = {
695
1032
  team: C.faint,
696
1033
  other: C.faint,
697
1034
  };
698
- export function renderText(s) {
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 = {}) {
699
1058
  const out = [];
700
1059
  const r = s.run;
1060
+ const W = opts.width ?? 120;
1061
+ const nameW = Math.max(6, ...s.agents.map((a) => a.length));
701
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);
702
1066
  const line = (it) => {
703
- const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : who(it.agent);
1067
+ const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : whoPad(it.agent);
704
1068
  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")
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)
707
1074
  for (const l of it.detail.split("\n").slice(0, 6))
708
- out.push(C.faint(` ${l}`));
1075
+ out.push(C.faint(`${indent}${l}`));
709
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);
710
1080
  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(" ")}`);
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
+ }
714
1109
  if (s.design.length) {
715
- out.push("", C.violet("DESIGN COUNCIL"));
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`);
716
1112
  s.design.forEach(line);
717
1113
  }
718
1114
  if (s.plan.tasks.length) {
@@ -721,21 +1117,60 @@ export function renderText(s) {
721
1117
  s.plan.tasks.forEach((t, i) => {
722
1118
  const deps = t.deps.map((d) => "#" + (num.get(d) ?? d));
723
1119
  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}` : ""]
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}` : ""]
725
1121
  .filter(Boolean)
726
1122
  .map((x) => " · " + x)
727
1123
  .join("")));
728
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
+ }
729
1134
  }
730
- out.push("", C.violet("TIMELINE"));
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");
731
1146
  if (!s.items.length)
732
1147
  out.push(C.faint(" (nothing happened in this run)"));
733
- s.items.forEach(line);
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}`);
734
1159
  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("")));
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("")));
739
1174
  for (const a of s.agents) {
740
1175
  const x = s.stats.get(a);
741
1176
  const vals = [
@@ -743,16 +1178,15 @@ export function renderText(s) {
743
1178
  x.failed,
744
1179
  x.reviews + (x.changesAsked ? ` (${x.changesAsked}✎)` : ""),
745
1180
  `${x.sent}/${x.received}`,
746
- x.proposals || x.critiques ? `${x.proposals}p ${x.critiques}c` : "0",
747
1181
  `${x.bugsFound}/${x.bugsFixed}`,
748
1182
  `${x.takeoversIn}/${x.takeoversOut}`,
749
1183
  x.toolCalls,
750
1184
  x.ms ? fmtDuration(x.ms) : "—",
751
1185
  x.tokens ? fmtTokens(x.tokens) : "—",
752
1186
  ].map(String);
753
- out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, Math.max(cols[i].length, 6) + 2)).join("")}`);
1187
+ out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, cols[i][1])).join("")}`);
754
1188
  }
755
- out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("(row → column: messages, questions, answers, reviews, critiques, handoffs)")}`);
1189
+ out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("· row → column: messages, reviews, critiques, handoffs")}`);
756
1190
  const cw = Math.max(8, ...s.agents.map((a) => a.length + 2));
757
1191
  out.push(" " + pad("", w) + s.agents.map((a) => pad(a, cw)).join(""));
758
1192
  for (const a of s.agents) {
@@ -760,7 +1194,7 @@ export function renderText(s) {
760
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("")}`);
761
1195
  }
762
1196
  }
763
- return out.join("\n");
1197
+ return (opts.width ? out.map((l) => truncate(l, Math.max(20, opts.width - 1))) : out).join("\n");
764
1198
  }
765
1199
  export function renderRunList(runs) {
766
1200
  if (!runs.length)
@@ -776,12 +1210,95 @@ export function renderRunList(runs) {
776
1210
  // ---- HTML (a static, self-contained report file) --------------------------------
777
1211
  const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
778
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";
779
1215
  export function renderHtml(s) {
780
1216
  const r = s.run;
1217
+ const c = s.collab;
1218
+ const o = outcome(s);
781
1219
  const span = Math.max(1, r.end - r.start);
782
1220
  const pct = (ts) => (((ts - r.start) / span) * 100).toFixed(3);
783
1221
  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.
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.
785
1302
  const ticks = Array.from({ length: 5 }, (_, i) => `<span style="left:${i * 25}%">${esc(clock((span * i) / 4))}</span>`).join("");
786
1303
  const lanes = s.agents
787
1304
  .map((a) => {
@@ -790,19 +1307,50 @@ export function renderHtml(s) {
790
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>`)
791
1308
  .join("");
792
1309
  const marks = [...s.items, ...s.design]
793
- .filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail", "announce"].includes(it.kind))
1310
+ .filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail"].includes(it.kind))
794
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>`)
795
1312
  .join("");
796
1313
  return `<div class="lane" data-lane="${esc(a)}"><div class="name">${chip(a)}</div><div class="track">${bars}${marks}</div></div>`;
797
1314
  })
798
1315
  .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>`;
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>`;
800
1319
  const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
801
1320
  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>`)
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>`)
803
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
+ : "";
804
1352
  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
1353
+ .map(([pair, msgs]) => `<details><summary>${esc(pair)} <small>${msgs.length} message${msgs.length > 1 ? "s" : ""}</small></summary><ol class="thread">${msgs
806
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>`)
807
1355
  .join("")}</ol></details>`)
808
1356
  .join("");
@@ -822,59 +1370,107 @@ export function renderHtml(s) {
822
1370
  <meta name="viewport" content="width=device-width, initial-scale=1">
823
1371
  <title>Huddle story</title>
824
1372
  <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}
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}
828
1376
  *{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)}
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)}
835
1412
  .lanes{min-width:640px;padding:8px 12px 12px}
836
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%)}
837
1414
  .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
838
1415
  .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
839
1416
  .track{position:relative;flex:1;height:26px}
840
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}
841
- .bar span{opacity:.9}.bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
1418
+ .bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
842
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)}
843
1420
  .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)}
1421
+ .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}
845
1422
  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)}
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}
850
1436
  .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}
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}
853
1441
  .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
854
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}
855
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}
856
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}
857
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))}}
858
1449
  </style>
859
1450
  </head>
860
1451
  <body>
861
1452
  <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>'}
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}` : ""}
872
1469
  <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>
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>
874
1471
  <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>
1472
+ <div class="scroll card"><table>${matrix}</table></div>
1473
+ <footer>made by huddle story · one file, no network</footer>
878
1474
  </main>
879
1475
  </body>
880
1476
  </html>
@@ -927,6 +1523,7 @@ export async function runStory(args) {
927
1523
  console.log(`${C.green("✓")} wrote ${out} ${C.faint(`· one file, no network · ${story.agents.length} lanes, ${story.items.length} events`)}`);
928
1524
  return;
929
1525
  }
930
- console.log(renderText(story));
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 }));
931
1528
  }
932
1529
  //# sourceMappingURL=story.js.map