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
@@ -0,0 +1,1194 @@
1
+ // `huddle runs` and `huddle export` — a run's history the way chat apps keep
2
+ // one: the summary, the conversation, and each agent's own session.
3
+ //
4
+ // huddle runs every run in this repo: when, goal, agents, result, cost
5
+ // huddle export [<run>|latest] the latest run as a chat (Markdown)
6
+ // --level summary|chat|full summary: result, who did what, decisions, teamwork, cost
7
+ // chat: + every message in time order, in full (default)
8
+ // full: + each agent's session per task (said / ran / wrote)
9
+ // --format md|html|json (default md; or from --out's extension)
10
+ // --out <path> a file, a folder, or - for stdout
11
+ // (default <repo>/.huddle/exports/run-<n>-<slug>.<ext>)
12
+ // --no-redact keep likely secrets (redacted by default)
13
+ //
14
+ // Everything comes from what `huddle story` reads (src/ui/story.ts: splitRuns,
15
+ // buildStory and its summaries) plus the agents' logs (src/ui/transcript.ts).
16
+ // The story shortens lines for a timeline; the export keeps every word.
17
+ //
18
+ // exportRun(db, run, {level, format}) is the one entry point for other callers
19
+ // (the hand-off brief, a dashboard): it returns the document as a string.
20
+ //
21
+ // ---- The JSON schema (format "json"), version 1 ------------------------------------
22
+ // Stable: fields are only ever added. Times are ms since the epoch; `t` is
23
+ // "+m:ss" from the run's start. Every list is present at every level (empty
24
+ // when the level leaves it out).
25
+ //
26
+ // {
27
+ // schema: "huddle.export", version: 1, level: "summary"|"chat"|"full",
28
+ // generated: ISO time, redacted: <secrets replaced; 0 when --no-redact>,
29
+ // run: { index, of, id, goal, title, start, end, ms },
30
+ // agents: [{ id, color, merged, failed, reviews, qa, sent, received, proposals,
31
+ // critiques, bugsFound, bugsFixed, takeoversIn, takeoversOut,
32
+ // toolCalls, ms, tokens, cached }],
33
+ // summary: {
34
+ // result: { merged, failed, merges, green, qaRounds, qa: {text, tone}|null, tokens, cached, ms },
35
+ // plan: { by, tasks: [{ id, title, deps, files, prefers, idea, doneBy, status }] },
36
+ // decisions: [{ risk, by, body }],
37
+ // contract: { by, text, amendments: [{ by, version, change }] } | null,
38
+ // teamwork: { tally: [{ n, label }], lines: [{ label, text }] },
39
+ // cost: [{ agent, tokens, cached, ms }]
40
+ // },
41
+ // messages: [{ i, ts, t, phase: "goal"|"council"|"plan"|"build"|"qa",
42
+ // kind, from, to: [], label, text, task?: {id, title}, replyTo? }],
43
+ // transcripts: { "<agent>-<task>": { agent, task, title, spec, file, found,
44
+ // format: "claude"|"codex"|"cursor"|"raw", bytes,
45
+ // turns: [{ kind: "prompt"|"text"|"thinking"|"tool"|"note"|"end",
46
+ // text, tool?, input?, output?, error?, ts? }] } }
47
+ // }
48
+ import fs from "node:fs";
49
+ import path from "node:path";
50
+ import { redactSecrets } from "../room/context.js";
51
+ import { fmtDuration, fmtTokens } from "../adapters/usage.js";
52
+ import { C, dot } from "./term.js";
53
+ import { STORY_CSS, agreement, buildStory, buddyNotes, clock, collabLines, colorOf, crewRows, esc, goalTitle, list, oneLine, outcome, phases, pick, pickRun, plural, splitRuns, str, tally, } from "./story.js";
54
+ import { readLog } from "./transcript.js";
55
+ export const EXPORT_LEVELS = ["summary", "chat", "full"];
56
+ export const EXPORT_FORMATS = ["md", "html", "json"];
57
+ export const EXPORT_SCHEMA_VERSION = 1;
58
+ /**
59
+ * A run's name: the goal's first sentence (a brief's first line can be a
60
+ * paragraph). Capped; the whole goal is always in the document too.
61
+ */
62
+ export function headline(goal, n = 100) {
63
+ const first = goal.trim().split(/\n/)[0] ?? "";
64
+ const sentence = /^(.{8,}?[.!?])(?:\s|$)/.exec(first)?.[1] ?? first;
65
+ return goalTitle(sentence, n);
66
+ }
67
+ /** Task text in the chat: a QA brief carries the whole goal and diff, so long ones are cut (the full export has them whole). */
68
+ const SPEC_IN_CHAT = 800;
69
+ // ---- the chat --------------------------------------------------------------------
70
+ const NOT_AGENTS = new Set(["huddle", "room", "all", "you", "council", "", "?"]);
71
+ /** The run's conversation, in time order, every message whole. */
72
+ export function chatOf(run, story) {
73
+ const out = [];
74
+ const phaseOf = new Map();
75
+ const titles = new Map();
76
+ const owner = new Map();
77
+ for (const e of run.events) {
78
+ if (e.type === "task.added")
79
+ titles.set(pick(e.p, "id") || e.task || "", pick(e.p, "title"));
80
+ else if (pick(e.p, "title") && e.task && !titles.has(e.task))
81
+ titles.set(e.task, pick(e.p, "title"));
82
+ }
83
+ const crew = story.agents;
84
+ const title = (task, p) => pick(p ?? {}, "title") || (task ? titles.get(task) ?? task : "");
85
+ const taskRef = (task, p) => (task ? { task: { id: task, title: title(task, p) } } : {});
86
+ const q = (s) => (s ? `“${s}”` : "");
87
+ const firstWork = run.events.find((e) => e.type === "run.start" || e.type === "task.claimed")?.ts ?? Infinity;
88
+ const hasRun = run.events.some((e) => e.type.startsWith("run."));
89
+ const roomTakeovers = run.events.some((e) => e.type === "task.takeover");
90
+ const viaScheduler = (e) => run.events.some((x) => x.type === "run.start" && x.task === e.task && x.agent === e.agent && Math.abs(x.ts - e.ts) < 3000);
91
+ // Notes Huddle delivered (buddy notes, radar findings): their message ids, so a reply can say what it answers.
92
+ const noteBy = new Map();
93
+ const noteAuthor = new Map();
94
+ const msgBy = new Map();
95
+ const recent = [];
96
+ let planned = false;
97
+ let lastMsg = null;
98
+ // Phases by the order events were written, not their clocks (a fast machine writes many in one ms).
99
+ let working = false;
100
+ let checking = false;
101
+ const push = (m, phase) => {
102
+ out.push(m);
103
+ phaseOf.set(m, phase ?? (checking ? "qa" : working ? "build" : /^(proposal|critique|adopt|flaw|ratify|contract)$/.test(m.kind) ? "council" : "plan"));
104
+ lastMsg = m;
105
+ return m;
106
+ };
107
+ const sys = (ts, label, text = "", extra = {}) => push({ ts, kind: "system", from: "huddle", to: [], label, text, ...extra });
108
+ const plan = (ts) => {
109
+ if (planned || !story.plan.tasks.length)
110
+ return;
111
+ planned = true;
112
+ const num = new Map(story.plan.tasks.map((t, i) => [t.id, i + 1]));
113
+ const text = story.plan.tasks
114
+ .map((t, i) => {
115
+ const bits = [t.deps.length ? `after ${t.deps.map((d) => "#" + (num.get(d) ?? d)).join(", ")}` : "", t.prefers.length ? `suits ${t.prefers.join("/")}` : "", t.files.length ? t.files.join(", ") : "", t.by ? `idea from ${t.by}` : ""].filter(Boolean);
116
+ return `${i + 1}. ${t.title}${bits.length ? ` — ${bits.join(" · ")}` : ""}`;
117
+ })
118
+ .join("\n");
119
+ // "council (claude, codex, cursor) → claude": the council designed it, claude wrote it down.
120
+ const by = story.plan.by || "huddle";
121
+ const writer = /→\s*(\S+)\s*$/.exec(by)?.[1] ?? by;
122
+ push({ ts, kind: "plan", from: writer, to: crew.length ? ["all"] : [], label: `the plan: ${plural(story.plan.tasks.length, "task")}${writer !== by ? ` (by ${by})` : ""}`, text }, "plan");
123
+ };
124
+ for (const e of run.events) {
125
+ const p = e.p;
126
+ const ts = e.ts;
127
+ if (!planned && ts >= firstWork && (e.type === "run.start" || e.type === "task.claimed"))
128
+ plan(ts);
129
+ if (e.type === "run.start" || e.type === "task.claimed")
130
+ working = true;
131
+ if (e.type === "qa.round")
132
+ checking = true;
133
+ if (/^(council\.|takeover|split|task\.takeover|task\.split|qa\.)/.test(e.type)) {
134
+ const sig = [e.type, e.task, pick(p, "from", "agent", "by") || e.agent, pick(p, "to"), oneLine(pick(p, "title", "summary", "body", "reason", "text"), 60), p.round === undefined ? "" : str(p.round), list(p.agents).join(",")].join("|");
135
+ if (recent.some((r) => r.sig === sig && ts - r.ts < 2000))
136
+ continue;
137
+ recent.push({ sig, ts });
138
+ }
139
+ switch (e.type) {
140
+ case "session.created":
141
+ if (pick(p, "goal"))
142
+ push({ ts, kind: "goal", from: "you", to: crew.length ? crew : ["all"], label: "the goal", text: pick(p, "goal") }, "goal");
143
+ break;
144
+ case "board.cleared":
145
+ case "agent.status":
146
+ case "agent.joined":
147
+ case "handoff.ready":
148
+ case "run.tokens":
149
+ case "run.handoff":
150
+ case "run.contract":
151
+ case "plug.call":
152
+ case "context.search":
153
+ case "context.read":
154
+ case "context.removed":
155
+ case "offer":
156
+ case "radar.finding":
157
+ case "radar.stats":
158
+ case "followup":
159
+ case "note.open":
160
+ case "buddy.start":
161
+ case "buddy.end":
162
+ break;
163
+ case "plan.made":
164
+ plan(ts);
165
+ break;
166
+ case "task.added": {
167
+ if (ts <= firstWork)
168
+ break; // in the plan
169
+ const by = pick(p, "proposed_by", "by", "author", "filed_by");
170
+ const spec = pick(p, "spec");
171
+ const cut = spec.length > SPEC_IN_CHAT ? spec.slice(0, SPEC_IN_CHAT).replace(/\s+\S*$/, "") + `\n… (${(spec.length - SPEC_IN_CHAT).toLocaleString("en-US")} more characters: the whole task is under Sessions in --level full)` : spec;
172
+ push({ ts, kind: "system", from: by && !NOT_AGENTS.has(by) ? by : "huddle", to: [], label: `new task on the board: ${q(pick(p, "title"))}`, text: cut, ...taskRef(pick(p, "id") || e.task, p) });
173
+ break;
174
+ }
175
+ case "task.claimed":
176
+ if (e.task && e.agent)
177
+ owner.set(e.task, e.agent);
178
+ if (!viaScheduler(e))
179
+ sys(ts, `${e.agent ?? "?"} claims ${q(title(e.task))}`, "", taskRef(e.task));
180
+ break;
181
+ case "task.status": {
182
+ if (hasRun)
183
+ break;
184
+ const s = pick(p, "status");
185
+ if (["merged", "failed", "needs_you"].includes(s))
186
+ push({ ts, kind: s === "merged" ? "merge" : "fail", from: "huddle", to: [], label: `${s === "merged" ? "✓ merged" : s === "failed" ? "✗ failed" : "! needs you:"} ${q(title(e.task))}${e.task && owner.get(e.task) ? ` (${owner.get(e.task)})` : ""}`, text: "", ...taskRef(e.task) });
187
+ break;
188
+ }
189
+ case "task.progress": {
190
+ const note = pick(p, "note");
191
+ if (!note)
192
+ break;
193
+ push({ ts, kind: "progress", from: (e.task && owner.get(e.task)) || e.agent || "?", to: ["room"], label: `${str(p.pct ?? "?")}% on ${q(title(e.task))}`, text: note, ...taskRef(e.task) });
194
+ break;
195
+ }
196
+ case "plan.announced": {
197
+ const touches = list(p.touches);
198
+ const hits = Array.isArray(p.conflicts) ? p.conflicts : [];
199
+ const text = [pick(p, "intent"), touches.length ? `touches: ${touches.join(", ")}` : "", hits.length ? `overlaps: ${hits.map((h) => `${str(h.path)} (held by ${str(h.held_by)})`).join(", ")}` : ""].filter(Boolean).join("\n");
200
+ push({ ts, kind: "announce", from: e.agent ?? "?", to: ["room"], label: `announces its plan for ${q(title(e.task))}`, text, ...taskRef(e.task) });
201
+ break;
202
+ }
203
+ // An agent's question for the person at the keyboard (huddle_ask_user), and what came back.
204
+ case "user.ask": {
205
+ const opts = list(p.options);
206
+ push({
207
+ ts,
208
+ kind: "question",
209
+ from: pick(p, "from") || e.agent || "?",
210
+ to: ["you"],
211
+ label: "asks you",
212
+ text: [pick(p, "question"), pick(p, "why"), ...opts.map((o, i) => `${i + 1}. ${o}${i === 0 ? " (recommended)" : ""}`)].filter(Boolean).join("\n"),
213
+ });
214
+ break;
215
+ }
216
+ case "user.answer": {
217
+ const mine = pick(p, "by") === "you";
218
+ push({ ts, kind: "answer", from: mine ? "you" : "huddle", to: [pick(p, "from") || e.agent || "?"], label: mine ? "answers" : "no answer in time — goes with option 1", text: pick(p, "answer") });
219
+ break;
220
+ }
221
+ case "memory.added": {
222
+ const author = pick(p, "author") || e.agent || "";
223
+ const body = pick(p, "body");
224
+ const why = pick(p, "why");
225
+ // Your own words: already in the chat as the message or the answer they were.
226
+ if (author === "you")
227
+ break;
228
+ if (author === "huddle") {
229
+ if (/red test gate/.test(body))
230
+ push({ ts, kind: "fail", from: "huddle", to: [], label: "the test gate is red", text: body });
231
+ break;
232
+ }
233
+ if (/^Review of /.test(body))
234
+ break; // the review's own message says it
235
+ // Huddle's copies in an agent's name: a late buddy note, a reply ("claude → codex: …") — the chat has them already.
236
+ if (/^Buddy note on “|^Buddy note on "/.test(body) || /^\S+ → \S+: /.test(body))
237
+ break;
238
+ const done = /^Task (\S+) done:\s*/.exec(body);
239
+ if (done) {
240
+ push({ ts, kind: "done", from: author, to: ["room"], label: `done: ${q(title(done[1]))}`, text: body.slice(done[0].length), ...taskRef(done[1]) });
241
+ break;
242
+ }
243
+ if (author === "council") {
244
+ const risk = /risk/i.test(pick(p, "kind")) || /^council risk:/.test(body);
245
+ push({ ts, kind: "decision", from: "council", to: ["all"], label: risk ? `risk${/flagged by (\S+)/.exec(why)?.[1] ? " flagged by " + /flagged by (\S+)/.exec(why)[1] : ""}` : "decided", text: body.replace(/^council( risk)?:\s*/, "") }, working ? undefined : "plan");
246
+ break;
247
+ }
248
+ push({ ts, kind: "memory", from: author, to: ["shared memory"], label: `remembers (${pick(p, "kind") || "note"})`, text: [body, why ? `why: ${why}` : ""].filter(Boolean).join("\n") });
249
+ break;
250
+ }
251
+ case "agent.message":
252
+ case "note": {
253
+ const from = pick(p, "from") || e.agent || "?";
254
+ const to = pick(p, "to") || "?";
255
+ const body = pick(p, "body");
256
+ const kind = pick(p, "kind") || "note";
257
+ const id = pick(p, "id");
258
+ // Huddle's delivery of a buddy note / radar finding: the checkpoint and radar items carry them, once.
259
+ const buddy = /^buddy check from (\S+?)(?: at \d+%)?:/.exec(body);
260
+ if (from === "huddle" && buddy) {
261
+ if (id)
262
+ noteBy.set(id, `${buddy[1]}'s buddy note`);
263
+ if (id)
264
+ noteAuthor.set(id, buddy[1]);
265
+ break;
266
+ }
267
+ if (from === "huddle" && /^Huddle radar:/.test(body)) {
268
+ if (id)
269
+ noteBy.set(id, "the radar's finding");
270
+ break;
271
+ }
272
+ if (/^contract v\d+ by /.test(body))
273
+ break; // contract.amended says it
274
+ if (from === "huddle" && / is taking “.*” \(files .*\) in \d+s unless you reply/.test(body)) {
275
+ push({ ts, kind: "system", from: "huddle", to: [to], label: "proposes a pair", text: body, ...taskRef(e.task) });
276
+ if (id)
277
+ noteBy.set(id, "Huddle's pairing offer");
278
+ break;
279
+ }
280
+ if (from === "huddle" && /is free and can take part of your task|huddle_split/.test(body)) {
281
+ push({ ts, kind: "system", from: "huddle", to: [to], label: "offers help", text: body, ...taskRef(e.task) });
282
+ break;
283
+ }
284
+ const replyTo = pick(p, "reply_to");
285
+ const re = replyTo ? noteBy.get(replyTo) ?? msgBy.get(replyTo) : undefined;
286
+ // One message to "all" is written once per teammate: show it once, to all of them.
287
+ const prev = lastMsg;
288
+ if (prev && prev.from === from && prev.text === body && prev.kind !== "system" && Math.abs(prev.ts - ts) < 1500 && (prev.kind === "message" || prev.kind === "question" || prev.kind === "answer")) {
289
+ if (!prev.to.includes(to))
290
+ prev.to.push(to);
291
+ if (id)
292
+ msgBy.set(id, `${from}'s message`);
293
+ break;
294
+ }
295
+ const k = kind === "question" ? "question" : kind === "answer" ? "answer" : "message";
296
+ // A reply to a buddy note goes back through Huddle to the teammate who wrote it.
297
+ const back = to === "huddle" && replyTo ? noteAuthor.get(replyTo) : undefined;
298
+ push({ ts, kind: k, from, to: [back ?? to], label: k === "question" ? "asks" : k === "answer" ? "answers" : replyTo ? "replies" : "says", text: body, ...taskRef(e.task), ...(re ? { replyTo: re } : replyTo ? { replyTo: "an earlier message" } : {}) });
299
+ if (id)
300
+ msgBy.set(id, `${from}'s ${k === "question" ? "question" : "message"}`);
301
+ break;
302
+ }
303
+ case "ask":
304
+ if (p.routed)
305
+ break;
306
+ push({ ts, kind: "question", from: pick(p, "from") || e.agent || "?", to: [pick(p, "to") || "room"], label: "asks", text: pick(p, "question"), ...taskRef(e.task) });
307
+ break;
308
+ case "answer":
309
+ if (pick(p, "source") === "teammate")
310
+ break;
311
+ push({ ts, kind: "answer", from: pick(p, "from") || "room", to: pick(p, "to") ? [pick(p, "to")] : [], label: `answers (from ${pick(p, "source") || "shared memory"})`, text: pick(p, "answer") });
312
+ break;
313
+ case "handoff": {
314
+ const from = pick(p, "from") || e.agent || "?";
315
+ const to = pick(p, "to");
316
+ const files = list(p.files);
317
+ const syms = list(p.symbols);
318
+ // One handoff, written once per teammate who builds on it.
319
+ const prev = [...out].reverse().find((m) => m.kind === "handoff" && m.from === from && m.task?.id === pick(p, "task") && ts - m.ts < 5000);
320
+ if (prev) {
321
+ if (to && to !== from && !prev.to.includes(to))
322
+ prev.to.push(to);
323
+ break;
324
+ }
325
+ push({ ts, kind: "handoff", ...(pick(p, "task") ? { task: { id: pick(p, "task"), title: pick(p, "title") } } : {}), from, to: to && to !== from ? [to] : [], label: `hands off ${q(pick(p, "title"))}${to && to !== from ? "" : " to whoever builds on it"}`, text: [files.length ? `files: ${files.join(", ")}` : "", ...syms.map((s) => `· ${s}`)].filter(Boolean).join("\n") });
326
+ break;
327
+ }
328
+ case "message":
329
+ push({ ts, kind: "person", from: pick(p, "from") || "someone", to: ["room"], label: "says", text: pick(p, "body") });
330
+ break;
331
+ case "member.joined":
332
+ sys(ts, `${pick(p, "github") ? "@" + pick(p, "github") : pick(p, "name")} joined the room as ${pick(p, "role") || "member"}`);
333
+ break;
334
+ case "member.left":
335
+ sys(ts, `${pick(p, "name")} ${pick(p, "reason") || "left"}`);
336
+ break;
337
+ case "crew.updated":
338
+ sys(ts, `${pick(p, "member")}'s crew: ${list(p.agents).join(", ") || "(none)"}`);
339
+ break;
340
+ case "assign":
341
+ sys(ts, `offered ${q(pick(p.task ?? {}, "title") || title(e.task))} on ${pick(p, "worker") || "a teammate"}'s machine`);
342
+ break;
343
+ case "live.joined":
344
+ sys(ts, `${e.agent ?? "?"} joined live${pick(p, "branch") ? " on " + pick(p, "branch") : ""}`);
345
+ break;
346
+ case "live.left":
347
+ sys(ts, `${e.agent ?? "?"} left${pick(p, "why") ? " · " + pick(p, "why") : ""}`);
348
+ break;
349
+ case "live.released":
350
+ sys(ts, `${e.agent ?? "?"} released ${list(p.paths).join(", ")}`);
351
+ break;
352
+ case "context.added":
353
+ push({ ts, kind: "screen", from: pick(p, "by") || "you", to: ["shared screen"], label: `puts ${pick(p, "id")} ${q(pick(p, "title"))} on the shared screen`, text: pick(p, "summary") });
354
+ break;
355
+ case "context.note":
356
+ push({ ts, kind: "screen", from: pick(p, "by") || e.agent || "you", to: ["shared screen"], label: `pins a note on ${pick(p, "id")} ${q(pick(p, "title"))}`, text: pick(p, "body") });
357
+ break;
358
+ // ---- the scheduler ----
359
+ case "run.start": {
360
+ const a = pick(p, "agent") || e.agent || "?";
361
+ if (e.task)
362
+ owner.set(e.task, a);
363
+ sys(ts, `${a} ${p.fixing ? "starts a fix round on" : p.round !== undefined ? `starts QA round ${str(p.round)} on` : "starts"} ${q(title(e.task, p))}${p.remote ? ` · on ${pick(p, "remote")}'s machine` : ""}${p.takeover ? ` · taking over from ${pick(p, "takeover")}` : ""}`, "", taskRef(e.task, p));
364
+ break;
365
+ }
366
+ case "run.declined":
367
+ sys(ts, `${pick(p, "agent") || e.agent} declined ${q(title(e.task, p))} · back on the board`, "", taskRef(e.task, p));
368
+ break;
369
+ case "run.review":
370
+ sys(ts, `${pick(p, "agent") || e.agent} reviews ${pick(p, "author")}'s ${q(title(e.task, p))}`, "", taskRef(e.task, p));
371
+ break;
372
+ case "run.reviewed": {
373
+ const a = pick(p, "agent") || e.agent || "?";
374
+ const author = pick(p, "author");
375
+ push({ ts, kind: "review", from: a, to: author ? [author] : [], label: p.skipped ? "review skipped" : p.approve === false ? "asks for changes" : `approves ${q(title(e.task, p))}`, text: pick(p, "notes"), ...taskRef(e.task, p) });
376
+ break;
377
+ }
378
+ case "run.merged":
379
+ push({ ts, kind: "merge", from: "huddle", to: [], label: `✓ merged ${pick(p, "agent") || e.agent}'s ${q(title(e.task, p))}`, text: [pick(p, "reason"), p.ms ? fmtDuration(Number(p.ms)) : "", p.tokens ? `${fmtTokens(Number(p.tokens))} tokens` : ""].filter(Boolean).join(" · "), ...taskRef(e.task, p) });
380
+ break;
381
+ case "run.failed":
382
+ push({ ts, kind: "fail", from: "huddle", to: [], label: `${p.needsYou ? "! needs you:" : "✗ failed:"} ${pick(p, "agent") || e.agent}'s ${q(title(e.task, p))}`, text: pick(p, "reason"), ...taskRef(e.task, p) });
383
+ break;
384
+ case "run.stuck":
385
+ push({ ts, kind: "fail", from: "huddle", to: [], label: "blocked by failures", text: list(p.blocked).join("\n") });
386
+ break;
387
+ case "run.conflict": {
388
+ const a = pick(p, "agent") || e.agent || "?";
389
+ const author = pick(p, "author");
390
+ sys(ts, `${a} resolves a merge conflict in ${author === a ? "its own" : author + "'s"} ${q(title(e.task, p))} (try ${str(p.attempt)})`, list(p.files).join(", "), taskRef(e.task, p));
391
+ break;
392
+ }
393
+ case "run.resolved":
394
+ sys(ts, `${p.ok ? "✓ conflict resolved" : "✗ resolution rejected"}: ${q(title(e.task, p))}`, pick(p, "reason"), taskRef(e.task, p));
395
+ break;
396
+ // ---- the design council ----
397
+ case "council.proposal":
398
+ push({ ts, kind: "proposal", from: pick(p, "agent", "from", "by") || e.agent || "?", to: ["council"], label: "proposes a design", text: [pick(p, "text", "summary", "title", "proposal", "body", "plan"), pick(p, "risks", "details", "why")].filter(Boolean).join("\n\n") }, "council");
399
+ break;
400
+ case "council.critique": {
401
+ const a = pick(p, "agent", "from", "by", "critic") || e.agent || "?";
402
+ const of = pick(p, "of", "target", "to", "author");
403
+ const text = pick(p, "text", "body", "critique", "summary", "flaws");
404
+ // Council v2: ADOPT / FLAW lines are their own messages; here, the rest of what it said.
405
+ const v2 = Array.isArray(p.adopts) || Array.isArray(p.flaws) || /^\s*ADOPT\W/m.test(text);
406
+ const revised = v2 ? /REVISED\W*:?\s*([\s\S]*)$/i.exec(text)?.[1]?.trim() : undefined;
407
+ if (v2 && !revised)
408
+ break;
409
+ push({ ts, kind: "critique", from: a, to: of ? [of] : ["council"], label: revised ? "revised position" : `critiques ${of ? of + "'s proposal" : "the proposals"}`, text: revised ?? text }, "council");
410
+ break;
411
+ }
412
+ case "council.adopt":
413
+ case "council.flaw": {
414
+ const of = pick(p, "of");
415
+ push({ ts, kind: e.type === "council.adopt" ? "adopt" : "flaw", from: pick(p, "agent") || e.agent || "?", to: of ? [of] : [], label: e.type === "council.adopt" ? `adopts ${of}'s idea` : `finds a flaw in ${of}'s idea`, text: pick(p, "body", "text") }, "council");
416
+ break;
417
+ }
418
+ case "council.ratify": {
419
+ const obj = list(p.objections);
420
+ push({ ts, kind: "ratify", from: pick(p, "agent") || e.agent || "?", to: ["council"], label: obj.length ? "objects to the plan" : `ratifies the plan${pick(p, "verdict") === "unclear" ? " (no clear verdict)" : ""}`, text: obj.join("\n") }, "council");
421
+ break;
422
+ }
423
+ case "council.plan": {
424
+ const by = pick(p, "by", "agent", "merged_by") || e.agent || "council";
425
+ const tasks = Array.isArray(p.tasks) ? p.tasks : [];
426
+ const sy = p.synthesizer && typeof p.synthesizer === "object" ? p.synthesizer : null;
427
+ const objections = (Array.isArray(p.objections) ? p.objections : []).map((o) => `${o?.taken === true ? "✓ took" : "✗ kept the plan over"} ${str(o?.by)}'s objection: ${str(o?.objection)}`);
428
+ // The tasks themselves (with whose idea each was) are the PLAN message that follows.
429
+ const text = [
430
+ sy?.why ? `${sy.kind === "agent" ? "chosen to write it" : "the brain wrote it"}: ${str(sy.why)}` : "",
431
+ objections.join("\n"),
432
+ pick(p, "summary", "note"),
433
+ ]
434
+ .filter(Boolean)
435
+ .join("\n\n");
436
+ push({ ts, kind: "plan", from: by, to: list(p.members).length ? list(p.members).filter((m) => m !== by) : ["council"], label: `writes the plan${tasks.length ? `: ${plural(tasks.length, "task")}` : ""}`, text }, "council");
437
+ break;
438
+ }
439
+ case "council.skipped":
440
+ sys(ts, `${pick(p, "agent") || e.agent || "?"}: ${pick(p, "round") || "council"} skipped`, pick(p, "reason") || "no answer");
441
+ break;
442
+ case "contract.set":
443
+ if (pick(p, "text"))
444
+ push({ ts, kind: "contract", from: pick(p, "by") || e.agent || "council", to: ["all"], label: "writes the contract: the names every task codes to", text: str(p.text) }, working ? undefined : "council");
445
+ break;
446
+ case "contract.amended":
447
+ push({ ts, kind: "contract", from: pick(p, "by") || e.agent || "?", to: ["all"], label: `amends the contract (v${str(p.version)})`, text: [`${pick(p, "section")}: ${pick(p, "change")}`, pick(p, "why") ? `why: ${pick(p, "why")}` : ""].filter(Boolean).join("\n") });
448
+ break;
449
+ // ---- teamwork mid-task ----
450
+ case "checkpoint": {
451
+ const a = pick(p, "agent") || e.agent || "?";
452
+ const author = pick(p, "author");
453
+ const notes = pick(p, "notes");
454
+ const ok = !notes || /^LOOKS GOOD/i.test(notes.trim());
455
+ push({ ts, kind: "buddy", from: a, to: author ? [author] : [], label: `buddy check ${Number(p.pct) ? `at ${str(p.pct)}%` : "mid-task"} on ${q(title(e.task, p))}: ${ok ? "looks good" : p.late ? "notes (came back after it landed)" : "notes"}`, text: ok ? "" : notes, ...taskRef(e.task, p) });
456
+ break;
457
+ }
458
+ case "note.closed": {
459
+ const to = pick(p, "to") || e.agent || "?";
460
+ const by = pick(p, "by");
461
+ const how = pick(p, "how") || "ignored";
462
+ const what = pick(p, "kind") === "radar" ? "the radar's finding" : `${by}'s buddy note`;
463
+ push({ ts, kind: how === "ignored" ? "fail" : "system", from: "huddle", to: [], label: how === "ignored" ? `${to} never answered ${what} on ${q(title(e.task))}` : `${to} ${how === "replied" ? "answered" : "addressed (in code)"} ${what} on ${q(title(e.task))}`, text: "", ...taskRef(e.task) });
464
+ break;
465
+ }
466
+ case "done.refused":
467
+ sys(ts, `${e.agent ?? "?"} tried to finish ${q(title(e.task))} with ${plural(list(p.notes).length || 1, "teammate note")} unanswered · told to answer first`, "", taskRef(e.task));
468
+ break;
469
+ case "radar": {
470
+ const w = pick(p, "with");
471
+ push({ ts, kind: "radar", from: "huddle", to: [e.agent ?? pick(p, "agent"), ...(w && w !== "contract" ? [w] : [])].filter(Boolean), label: `radar: ${pick(p, "kind") === "overlap" ? "overlapping work" : "name mismatch"}${w ? ` (${e.agent ?? pick(p, "agent")} ↔ ${w})` : ""}`, text: pick(p, "detail"), ...taskRef(e.task) });
472
+ break;
473
+ }
474
+ case "pair":
475
+ push({ ts, kind: "system", from: pick(p, "agent") || e.agent || "?", to: pick(p, "with") ? [pick(p, "with")] : [], label: `pairs on ${q(title(e.task, p))}`, text: [pick(p, "part") ? `takes: ${pick(p, "part")}` : "", pick(p, "reason")].filter(Boolean).join("\n"), ...taskRef(e.task, p) });
476
+ break;
477
+ case "pair.dropped":
478
+ sys(ts, `${e.agent ?? "?"}'s offer to pair with ${pick(p, "with")} on ${q(title(e.task, p))} was dropped`, pick(p, "reason"), taskRef(e.task, p));
479
+ break;
480
+ case "task.takeover": {
481
+ const from = pick(p, "from");
482
+ const to = pick(p, "to") || e.agent || "?";
483
+ if (e.task)
484
+ owner.set(e.task, to);
485
+ sys(ts, pick(p, "kind") === "split" ? `${to} takes part of ${from}'s ${q(title(e.task, p))}` : `${to} takes over ${q(title(e.task, p))} from ${from}`, pick(p, "reason"), taskRef(e.task, p));
486
+ break;
487
+ }
488
+ case "takeover":
489
+ if (roomTakeovers)
490
+ break;
491
+ sys(ts, `${pick(p, "to", "agent", "by") || e.agent} takes over ${q(title(e.task, p))}${pick(p, "from", "was", "previous") ? ` from ${pick(p, "from", "was", "previous")}` : ""}`, pick(p, "reason", "why"), taskRef(e.task, p));
492
+ break;
493
+ case "task.split":
494
+ sys(ts, `${pick(p, "from") || e.agent} splits ${q(pick(p, "title"))} off ${q(pick(p, "parent_title"))}`, list(p.files).join(", "));
495
+ break;
496
+ case "split": {
497
+ const parts = Array.isArray(p.into) ? p.into : Array.isArray(p.parts) ? p.parts : Array.isArray(p.tasks) ? p.tasks : [];
498
+ sys(ts, `${pick(p, "from", "agent", "by") || e.agent} splits ${q(title(e.task, p))}${list(p.to ?? p.agents).length ? " → " + list(p.to ?? p.agents).join(", ") : ""}`, parts.map((x) => (typeof x === "string" ? x : pick(x ?? {}, "title", "id"))).join("\n"));
499
+ break;
500
+ }
501
+ // ---- QA ----
502
+ case "qa.round":
503
+ push({ ts, kind: "qa", from: "huddle", to: list(p.agents), label: `QA round${p.round !== undefined ? " " + str(p.round) : ""} on the merged build${list(p.agents).length ? ` · checked by ${list(p.agents).join(", ")}` : ""}`, text: pick(p, "summary", "checks", "note") }, "qa");
504
+ break;
505
+ case "qa.bug": {
506
+ const a = pick(p, "agent", "by", "found_by", "reporter") || e.agent || "?";
507
+ const to = pick(p, "to", "owner", "assigned_to", "author");
508
+ push({ ts, kind: "bug", from: a, to: to ? [to] : ["room"], label: `QA bug: ${pick(p, "title", "summary", "bug")}`, text: [pick(p, "repro", "details", "spec", "body"), pick(p, "expected") ? `expected: ${pick(p, "expected")}` : "", pick(p, "actual") ? `actual: ${pick(p, "actual")}` : ""].filter(Boolean).join("\n"), ...taskRef(e.task, p) });
509
+ break;
510
+ }
511
+ case "qa.done":
512
+ sys(ts, `${e.agent || pick(p, "agent")}: ${p.ok === false ? "QA check didn't finish" : "QA check done"} · ${plural(Number(p.bugs ?? 0), "bug")} filed`, p.ms ? fmtDuration(Number(p.ms)) : "");
513
+ break;
514
+ case "qa.clean":
515
+ push({ ts, kind: "qa", from: pick(p, "agent", "by") || e.agent || "huddle", to: [], label: `QA${p.round !== undefined ? " round " + str(p.round) : ""} clean`, text: pick(p, "summary", "note") });
516
+ break;
517
+ case "qa.suspect":
518
+ sys(ts, `QA round ${str(p.round)} came back clean, but quickly · another agent checks again`, pick(p, "reason"));
519
+ break;
520
+ case "qa.unchecked":
521
+ push({ ts, kind: "fail", from: "huddle", to: [], label: `QA round ${str(p.round)}: the build was NOT checked`, text: pick(p, "reason") });
522
+ break;
523
+ case "qa.capped":
524
+ sys(ts, `QA stopped after round ${str(p.round)} · the last fixes weren't re-checked${Number(p.open) ? ` · ${str(p.open)} still open` : ""}`);
525
+ break;
526
+ case "qa.fixes":
527
+ sys(ts, `QA round ${str(p.round)} fixes: ${str(p.merged ?? 0)} merged, ${str(p.failed ?? 0)} failed`);
528
+ break;
529
+ case "qa.duplicate":
530
+ sys(ts, `${q(pick(p, "title"))} is a duplicate of ${q(title(pick(p, "of")))} · not filed again`);
531
+ break;
532
+ case "qa.fixed":
533
+ case "qa.fix":
534
+ push({ ts, kind: "qa", from: pick(p, "agent", "by", "fixed_by") || e.agent || "?", to: [], label: `fixed: ${pick(p, "title", "summary", "bug") || title(e.task)}`, text: "" });
535
+ break;
536
+ default: {
537
+ // Newer (or unknown) events: every field, so nothing a run recorded is lost.
538
+ const fields = Object.entries(p)
539
+ .filter(([, v]) => v !== null && v !== undefined && v !== "")
540
+ .map(([k, v]) => `${k}: ${str(v)}`)
541
+ .join("\n");
542
+ const a = pick(p, "agent", "from", "by") || e.agent || "huddle";
543
+ push({ ts, kind: "system", from: a, to: pick(p, "to") ? [pick(p, "to")] : [], label: `[${e.type}]${e.task ? " " + q(title(e.task)) : ""}`, text: fields });
544
+ }
545
+ }
546
+ }
547
+ if (!planned)
548
+ plan(run.events.find((e) => e.type === "plan.made")?.ts ?? run.start);
549
+ const RANK = { goal: 0, council: 1, plan: 2, build: 3, qa: 3 };
550
+ const phased = out.map((m, k) => {
551
+ return { m, k, phase: phaseOf.get(m) };
552
+ });
553
+ // The council is recorded in one go when it ends, at the instant the plan lands: goal → council → plan, then time order.
554
+ phased.sort((a, b) => RANK[a.phase] - RANK[b.phase] || a.m.ts - b.m.ts || a.k - b.k);
555
+ return phased.map(({ m, phase }, i) => ({ i: i + 1, t: clock(m.ts - run.start), phase, ...m }));
556
+ }
557
+ // ---- transcripts -----------------------------------------------------------------
558
+ /** Each agent session in the run (a task, a fix round, a QA check), with its log. */
559
+ export function sessionsOf(run) {
560
+ const seen = new Map();
561
+ const titles = new Map();
562
+ for (const e of run.events)
563
+ if (e.type === "task.added")
564
+ titles.set(pick(e.p, "id") || e.task || "", pick(e.p, "title"));
565
+ for (const e of run.events) {
566
+ if (!["run.start", "run.conflict", "task.claimed"].includes(e.type))
567
+ continue;
568
+ const agent = pick(e.p, "agent") || e.agent || "";
569
+ const task = e.task || pick(e.p, "task");
570
+ if (!agent || !task)
571
+ continue;
572
+ const log = pick(e.p, "log");
573
+ const key = log ? path.basename(log, ".log") : `${agent}-${task}`.replace(/[^\w.-]/g, "_");
574
+ if (!seen.has(key))
575
+ seen.set(key, { agent, task, title: pick(e.p, "title") || titles.get(task) || task, log });
576
+ }
577
+ return [...seen.entries()].map(([key, s]) => ({ ...s, log: s.log || key + ".log" }));
578
+ }
579
+ function transcriptsOf(run, logsDir, repoRoot) {
580
+ const out = {};
581
+ const specs = new Map();
582
+ for (const e of run.events)
583
+ if (e.type === "task.added")
584
+ specs.set(pick(e.p, "id") || e.task || "", pick(e.p, "spec"));
585
+ for (const s of sessionsOf(run)) {
586
+ const key = path.basename(s.log, ".log");
587
+ // Where it was written (absolute, maybe another machine's path) — else the same name in this repo's logs/.
588
+ const local = path.join(logsDir, path.basename(s.log));
589
+ const file = fs.existsSync(local) ? local : path.isAbsolute(s.log) && fs.existsSync(s.log) ? s.log : local;
590
+ const parsed = readLog(file, s.agent);
591
+ const rel = path.relative(repoRoot, file);
592
+ out[key] = {
593
+ agent: s.agent,
594
+ task: s.task,
595
+ title: s.title,
596
+ spec: specs.get(s.task) ?? "",
597
+ file: rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : file,
598
+ found: !!parsed,
599
+ format: parsed?.format ?? "raw",
600
+ bytes: parsed?.bytes ?? 0,
601
+ turns: parsed?.turns ?? [],
602
+ };
603
+ }
604
+ return out;
605
+ }
606
+ // ---- the document ----------------------------------------------------------------
607
+ /** Every string in `v`, through the shared screen's secret scanner. */
608
+ function redactDeep(v, count) {
609
+ if (typeof v === "string") {
610
+ const r = redactSecrets(v);
611
+ count.n += r.count;
612
+ return r.text;
613
+ }
614
+ if (Array.isArray(v))
615
+ return v.map((x) => redactDeep(x, count));
616
+ if (v && typeof v === "object") {
617
+ const o = {};
618
+ for (const [k, x] of Object.entries(v))
619
+ o[k] = redactDeep(x, count);
620
+ return o;
621
+ }
622
+ return v;
623
+ }
624
+ /** One run as an export document (the JSON schema above); render it with renderExport. */
625
+ export function buildExport(run, runs, opts) {
626
+ const s = buildStory(run, runs);
627
+ const o = outcome(s);
628
+ const crew = crewRows(s);
629
+ const doneBy = new Map();
630
+ for (const e of run.events) {
631
+ if (!e.task)
632
+ continue;
633
+ if (e.type === "run.merged")
634
+ doneBy.set(e.task, { agent: pick(e.p, "agent") || e.agent || "", status: "merged" });
635
+ else if (e.type === "run.failed" && doneBy.get(e.task)?.status !== "merged")
636
+ doneBy.set(e.task, { agent: pick(e.p, "agent") || e.agent || "", status: p2(e.p) });
637
+ else if (e.type === "task.status" && ["merged", "failed"].includes(pick(e.p, "status")) && !doneBy.has(e.task))
638
+ doneBy.set(e.task, { agent: e.agent || "", status: pick(e.p, "status") });
639
+ }
640
+ const contractEv = [...run.events].reverse().find((e) => e.type === "contract.set" && pick(e.p, "text"));
641
+ const doc = {
642
+ schema: "huddle.export",
643
+ version: EXPORT_SCHEMA_VERSION,
644
+ level: opts.level,
645
+ generated: new Date().toISOString(),
646
+ redacted: 0,
647
+ run: { index: run.index, of: runs, id: run.id, goal: run.goal, title: headline(run.goal), start: run.start, end: run.end, ms: run.end - run.start },
648
+ agents: s.agents.map((a) => ({ id: a, color: colorOf(a), qa: crew.find((c) => c.agent === a)?.qa ?? 0, ...s.stats.get(a) })),
649
+ summary: {
650
+ result: { merged: s.merged, failed: s.failed, merges: o.merges, green: o.green, qaRounds: o.rounds, qa: o.qa, tokens: o.tokens, cached: o.cached, ms: o.ms },
651
+ plan: {
652
+ by: s.plan.by ?? "",
653
+ tasks: s.plan.tasks.map((t) => ({ id: t.id, title: t.title, deps: t.deps, files: t.files, prefers: t.prefers, idea: t.by ?? "", doneBy: doneBy.get(t.id)?.agent ?? "", status: doneBy.get(t.id)?.status ?? "" })),
654
+ },
655
+ decisions: decisionsOf(run, s),
656
+ contract: s.collab.contract || s.collab.amendments.length ? { by: s.collab.contract?.by ?? "", text: contractEv ? str(contractEv.p.text) : "", amendments: s.collab.amendments } : null,
657
+ teamwork: { tally: tally(s).map((t) => ({ n: t.n, label: t.label })), lines: teamworkLines(s) },
658
+ cost: crew.map((c) => ({ agent: c.agent, tokens: c.tokens, cached: c.cached, ms: c.ms })),
659
+ },
660
+ messages: opts.level === "summary" ? [] : chatOf(run, s),
661
+ transcripts: opts.level === "full" ? transcriptsOf(run, opts.logsDir, opts.repoRoot) : {},
662
+ };
663
+ if (opts.redact === false)
664
+ return doc;
665
+ const count = { n: 0 };
666
+ const clean = redactDeep(doc, count);
667
+ clean.redacted = count.n;
668
+ return clean;
669
+ }
670
+ const p2 = (p) => (p.needsYou ? "needs you" : "failed");
671
+ /** The council's decisions and risks, whole (the story's timeline shortens them). */
672
+ function decisionsOf(run, s) {
673
+ const whole = run.events
674
+ .filter((e) => e.type === "memory.added" && pick(e.p, "author") === "council")
675
+ .map((e) => {
676
+ const body = pick(e.p, "body");
677
+ const risk = /^council risk:/.test(body) || pick(e.p, "kind") === "gotcha";
678
+ return { risk, by: risk ? /flagged by (\S+)/.exec(pick(e.p, "why"))?.[1] ?? "" : "", body: body.replace(/^council( risk)?:\s*/, "") };
679
+ });
680
+ if (whole.length)
681
+ return whole;
682
+ return phases(s).agreed.map((it) => {
683
+ const a = agreement(it);
684
+ return { risk: a.risk, by: a.by ?? "", body: a.body };
685
+ });
686
+ }
687
+ /**
688
+ * The teamwork, line by line and whole: every idea taken and flaw found,
689
+ * objections, buddy notes and what came of them, the radar, QA's bugs, pairs.
690
+ * (collabLines is the same list shortened for a terminal.)
691
+ */
692
+ export function teamworkLines(s) {
693
+ const c = s.collab;
694
+ const out = [];
695
+ for (const a of c.adopts)
696
+ out.push({ label: "adopted", text: `${a.by} took ${a.of}'s idea: ${a.text}` });
697
+ for (const f of c.flaws)
698
+ out.push({ label: "flaw", text: `${f.by} on ${f.of}'s idea: ${f.text}` });
699
+ if (c.synthesizer)
700
+ out.push({ label: "plan by", text: `${c.synthesizer.id}${c.synthesizer.why ? ` — ${c.synthesizer.why}` : ""}` });
701
+ for (const r of c.ratify)
702
+ out.push({ label: "ratify", text: r.verdict === "object" ? `${r.agent} objected: ${r.objections.join(" · ")}` : `${r.agent} ratified${r.verdict === "unclear" ? " (no clear verdict)" : ""}` });
703
+ for (const o of c.objections)
704
+ out.push({ label: "revision", text: `${o.taken ? "took" : "kept the plan over"} ${o.by}'s objection: ${o.objection}` });
705
+ for (const a of c.amendments)
706
+ out.push({ label: "contract", text: `v${a.version} by ${a.by}: ${a.change}` });
707
+ for (const b of buddyNotes(s))
708
+ out.push({ label: "buddy", text: `${b.agent} → ${b.author} at ${b.pct}% (${b.how}): ${b.notes}` });
709
+ const good = c.buddy.filter((b) => !b.notes).length;
710
+ if (good)
711
+ out.push({ label: "buddy", text: `${plural(good, "check")} looked good` });
712
+ for (const r of c.radar)
713
+ out.push({ label: "radar", text: `${r.kind === "overlap" ? "overlap" : "name mismatch"} ${r.agent} ↔ ${r.with}: ${r.detail}` });
714
+ if (!c.radar.length && c.radarLooks?.looks)
715
+ out.push({ label: "radar", text: `no catches · compared 2+ agents' work in progress ${plural(c.radarLooks.looks, "time")}` });
716
+ for (const f of c.followups.filter((x) => x.kind === "radar"))
717
+ out.push({ label: "radar", text: `${f.to} ${f.how === "ignored" ? "ignored" : f.how === "replied" ? "answered" : "fixed"} the radar's finding: ${f.note}` });
718
+ for (const b of c.bugs)
719
+ out.push({ label: "QA bug", text: `${b.by} found${b.to ? ` (fix for ${b.to})` : ""}: ${b.title}${b.fixedBy ? ` — fixed by ${b.fixedBy}` : " — still open"}` });
720
+ for (const p of c.pairs)
721
+ out.push({ label: "pair", text: `${p.agent} helped ${p.with}: ${p.part}` });
722
+ if (c.credit.size)
723
+ out.push({ label: "credit", text: [...c.credit.entries()].sort((a, b) => b[1] - a[1]).map(([a, k]) => `${a} ${plural(k, "task")}`).join(" · ") });
724
+ return out.length ? out : collabLines(s);
725
+ }
726
+ /** Which run: a number from `huddle runs`, a session id, "latest" (or nothing). */
727
+ export function findRun(runs, which) {
728
+ if (which === undefined || which === "" || which === "latest")
729
+ return runs.at(-1);
730
+ return pickRun(runs, String(which));
731
+ }
732
+ /**
733
+ * A run as a document, for any caller (the CLI, the hand-off brief, a dashboard).
734
+ * Throws when the run isn't there.
735
+ */
736
+ export function exportRun(db, runId, opts = {}) {
737
+ const runs = splitRuns(db.allEvents());
738
+ const run = findRun(runs, runId);
739
+ if (!run)
740
+ throw new Error(runs.length ? `no run ${runId} (there are ${runs.length})` : "no runs yet");
741
+ const huddle = path.dirname(db.path);
742
+ const doc = buildExport(run, runs.length, {
743
+ ...opts,
744
+ level: opts.level ?? "chat",
745
+ logsDir: opts.logsDir ?? path.join(huddle, "logs"),
746
+ repoRoot: opts.repoRoot ?? (path.basename(huddle) === ".huddle" ? path.dirname(huddle) : huddle),
747
+ });
748
+ return renderExport(doc, opts.format ?? "md");
749
+ }
750
+ export function renderExport(doc, format) {
751
+ return format === "json" ? JSON.stringify(doc, null, 2) + "\n" : format === "html" ? renderExportHtml(doc) : renderMarkdown(doc);
752
+ }
753
+ // ---- Markdown ----------------------------------------------------------------------
754
+ const PHASE_NAME = { goal: "GOAL", council: "COUNCIL", plan: "PLAN", build: "BUILD", qa: "QA" };
755
+ const PHASE_SUB = {
756
+ goal: "what was asked",
757
+ council: "the agents design it together before anyone codes",
758
+ plan: "who does what",
759
+ build: "agents code, check each other, merge",
760
+ qa: "agents re-check the merged build; bugs become fix tasks",
761
+ };
762
+ /** A fence longer than any run of backticks inside. */
763
+ const fence = (s) => "`".repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map((m) => m[0].length + 1)));
764
+ const code = (s, lang = "text") => {
765
+ const f = fence(s);
766
+ return `${f}${lang}\n${s.replace(/\s+$/, "")}\n${f}`;
767
+ };
768
+ const quote = (s) => s
769
+ .replace(/\s+$/, "")
770
+ .split("\n")
771
+ .map((l) => (l ? `> ${l}` : ">"))
772
+ .join("\n");
773
+ /** Inline text in Markdown: no accidental emphasis or HTML from names and labels. */
774
+ const md = (s) => s.replace(/([\\`*_<>[\]|])/g, "\\$1");
775
+ /** A list item whose text may run over several lines: the rest stays inside the item. */
776
+ const li = (s) => "- " + s.replace(/\s+$/, "").replace(/\n/g, "\n ");
777
+ const toLine = (m) => (m.to.length ? ` → ${m.to.map(md).join(", ")}` : "");
778
+ function header(doc) {
779
+ const r = doc.run;
780
+ const res = doc.summary.result;
781
+ const out = [
782
+ `# ${md(r.title || "(no goal)")}`,
783
+ "",
784
+ `_Huddle run ${r.index} of ${r.of}${r.id ? ` · ${r.id}` : ""} · ${new Date(r.start).toLocaleString()} · ${fmtDuration(r.ms)} · ${doc.agents.map((a) => a.id).join(", ") || "no agents"} · exported at level “${doc.level}”${doc.redacted ? ` · ${plural(doc.redacted, "likely secret")} redacted` : ""}_`,
785
+ "",
786
+ `**Result:** ${res.failed ? "◎" : "✓"} ${res.merged} merged, ${res.failed} failed${res.merges ? ` · ${res.green}/${res.merges} merged on green tests` : ""}${res.tokens ? ` · ${fmtTokens(res.tokens)} tokens` : ""}${res.qa ? ` · ${plural(res.qaRounds, "QA round")}: ${res.qa.text}` : ""}`,
787
+ ];
788
+ if (r.goal.trim() !== r.title)
789
+ out.push("", "<details><summary>The goal, as typed</summary>", "", code(r.goal), "", "</details>");
790
+ return out;
791
+ }
792
+ function summaryMd(doc) {
793
+ const s = doc.summary;
794
+ const out = ["", "## Summary"];
795
+ if (doc.agents.length) {
796
+ out.push("", "### Who did what", "", "| agent | merged | failed | reviews | QA | msgs sent/got | ideas proposed/critiques | bugs found/fixed | tool calls | time | tokens |", "|---|---|---|---|---|---|---|---|---|---|---|");
797
+ for (const a of doc.agents)
798
+ out.push(`| ${md(a.id)} | ${a.merged} | ${a.failed} | ${a.reviews}${a.changesAsked ? ` (${a.changesAsked} asked for changes)` : ""} | ${a.qa} | ${a.sent}/${a.received} | ${a.proposals}/${a.critiques} | ${a.bugsFound}/${a.bugsFixed} | ${a.toolCalls} | ${a.ms ? fmtDuration(a.ms) : "—"} | ${a.tokens ? fmtTokens(a.tokens) : "—"} |`);
799
+ }
800
+ if (s.plan.tasks.length) {
801
+ out.push("", `### Plan${s.plan.by ? ` · by ${md(s.plan.by)}` : ""}`, "");
802
+ const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
803
+ s.plan.tasks.forEach((t, i) => {
804
+ const bits = [
805
+ t.doneBy ? `${t.status === "merged" ? "✓" : "✗"} ${t.status} · ${t.doneBy}` : "",
806
+ t.idea ? `idea from ${t.idea}` : "",
807
+ t.deps.length ? `after ${t.deps.map((d) => "#" + (num.get(d) ?? d)).join(", ")}` : "",
808
+ t.files.length ? t.files.map((f) => "`" + f + "`").join(", ") : "",
809
+ ].filter(Boolean);
810
+ out.push(`${i + 1}. **${md(t.title)}**${bits.length ? " — " + bits.join(" · ") : ""}`);
811
+ });
812
+ }
813
+ if (s.decisions.length) {
814
+ out.push("", "### Decisions", "");
815
+ for (const d of s.decisions)
816
+ out.push(li(`${d.risk ? `**risk**${d.by ? ` (${md(d.by)})` : ""}:` : "**decided:**"} ${d.body}`));
817
+ }
818
+ if (s.contract) {
819
+ out.push("", `### Contract${s.contract.by ? ` · by ${md(s.contract.by)}` : ""}`, "");
820
+ if (s.contract.text)
821
+ out.push("<details><summary>The contract every task coded to</summary>", "", code(s.contract.text, "markdown"), "", "</details>");
822
+ for (const a of s.contract.amendments)
823
+ out.push(li(`v${a.version} by ${md(a.by)}: ${a.change}`));
824
+ }
825
+ if (s.teamwork.tally.length || s.teamwork.lines.length) {
826
+ out.push("", "### Teamwork", "");
827
+ if (s.teamwork.tally.length)
828
+ out.push(s.teamwork.tally.map((t) => `**${t.n}** ${t.label}`).join(" · "), "");
829
+ for (const l of s.teamwork.lines)
830
+ out.push(li(`${l.label ? `**${md(l.label)}** ` : ""}${l.text}`));
831
+ }
832
+ if (s.cost.some((c) => c.tokens || c.ms)) {
833
+ const tot = s.cost.reduce((n, c) => n + c.tokens, 0);
834
+ const cached = s.cost.reduce((n, c) => n + c.cached, 0);
835
+ out.push("", "### Cost", "", "| agent | tokens | of which cached | agent time |", "|---|---|---|---|");
836
+ for (const c of s.cost)
837
+ out.push(`| ${md(c.agent)} | ${c.tokens ? fmtTokens(c.tokens) : "—"} | ${c.cached ? fmtTokens(c.cached) : "—"} | ${c.ms ? fmtDuration(c.ms) : "—"} |`);
838
+ out.push(`| **all** | **${fmtTokens(tot)}** | ${fmtTokens(cached)} | ${fmtDuration(doc.run.ms)} start to finish |`);
839
+ }
840
+ return out;
841
+ }
842
+ function chatMd(doc) {
843
+ const out = ["", "## Chat", "", `_${plural(doc.messages.length, "message")}, in time order. Times are from the start of the run._`];
844
+ let phase = null;
845
+ for (const m of doc.messages) {
846
+ if (m.phase !== phase) {
847
+ phase = m.phase;
848
+ out.push("", `### ${PHASE_NAME[phase]} · ${PHASE_SUB[phase]}`);
849
+ }
850
+ const head = `**${md(m.from)}${toLine(m)}** · ${md(m.label)}${m.replyTo ? ` _(re: ${md(m.replyTo)})_` : ""} · \`${m.t}\``;
851
+ out.push("", head);
852
+ if (m.text)
853
+ out.push("", quote(m.text));
854
+ }
855
+ return out;
856
+ }
857
+ function transcriptsMd(doc) {
858
+ const list = Object.entries(doc.transcripts);
859
+ const out = ["", "## Sessions", "", `_Each agent's own session, per task: what it said, read, ran and wrote. Long outputs are cut (the whole thing is in the log)._`];
860
+ if (!list.length)
861
+ out.push("", "_No agent sessions in this run._");
862
+ for (const [key, t] of list) {
863
+ out.push("", `### ${md(t.agent)} · ${md(t.title)} · \`${key}\``, "", `_${t.found ? `${t.format === "raw" ? "plain text" : t.format + " log"} · ${plural(t.turns.length, "step")} · ${Math.max(1, Math.round(t.bytes / 1024))} KB` : "log not found"} · ${md(t.file)}_`);
864
+ if (t.spec)
865
+ out.push("", "<details><summary>The task, as given</summary>", "", code(t.spec), "", "</details>");
866
+ for (const turn of t.turns)
867
+ out.push("", ...turnMd(t.agent, turn));
868
+ }
869
+ return out;
870
+ }
871
+ function turnMd(agent, t) {
872
+ switch (t.kind) {
873
+ case "prompt":
874
+ return ["<details><summary>The prompt Huddle gave it</summary>", "", code(t.text), "", "</details>"];
875
+ case "text":
876
+ return [`**${md(agent)}:**`, "", quote(t.text)];
877
+ case "thinking":
878
+ return [`_${md(agent)} thinks:_`, "", quote(t.text.split("\n").map((l) => (l ? `_${l.replace(/_/g, "\\_")}_` : l)).join("\n"))];
879
+ case "tool": {
880
+ const head = `${t.error ? "✗" : "▸"} **${md(t.tool ?? "tool")}**${t.about ? ` _${md(t.about)}_` : ""}${t.input ? " " + (t.input.includes("\n") || t.input.length > 160 ? "" : "`" + t.input.replace(/`/g, "'") + "`") : ""}`;
881
+ const long = t.input && (t.input.includes("\n") || t.input.length > 160);
882
+ return [head, ...(long ? ["", code(t.input)] : []), ...(t.output ? ["", code(t.output)] : [])];
883
+ }
884
+ case "end":
885
+ return [`_— ${md(t.text)}_`];
886
+ default:
887
+ return [`_${md(t.text.split("\n")[0])}_${t.text.includes("\n") ? "\n\n" + code(t.text.split("\n").slice(1).join("\n")) : ""}`];
888
+ }
889
+ }
890
+ export function renderMarkdown(doc) {
891
+ const out = [...header(doc), ...summaryMd(doc)];
892
+ if (doc.level !== "summary")
893
+ out.push(...chatMd(doc));
894
+ if (doc.level === "full")
895
+ out.push(...transcriptsMd(doc));
896
+ out.push("", "---", `_Exported by huddle export · ${doc.generated}_`, "");
897
+ return out.join("\n");
898
+ }
899
+ // ---- HTML ----------------------------------------------------------------------------
900
+ const EXPORT_CSS = `
901
+ .filter{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:10px 0;margin:18px 0 0;background:var(--bg);border-bottom:1px solid var(--line)}
902
+ .filter b{font-size:12px;color:var(--mute);font-weight:600;margin-right:4px}
903
+ .filter input{position:absolute;opacity:0;pointer-events:none}
904
+ .filter label{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;border:1px solid var(--line);border-radius:999px;padding:3px 11px;cursor:pointer;user-select:none;background:var(--card)}
905
+ .filter label i{width:9px;height:9px;border-radius:50%;background:var(--c)}
906
+ .filter input:not(:checked)+label{opacity:.45;text-decoration:line-through}
907
+ .filter input:focus-visible+label{outline:2px solid var(--brand)}
908
+ .filter a{font-size:13px;color:var(--brand);margin-left:auto;text-decoration:none}
909
+ .chat{display:flex;flex-direction:column;gap:8px}
910
+ .msg{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--c);border-radius:12px;padding:8px 12px;min-width:0}
911
+ .msg .meta{display:flex;flex-wrap:wrap;gap:4px 8px;align-items:baseline;font-size:13px;color:var(--mute)}
912
+ .msg .meta .label{color:var(--fg);font-weight:600}
913
+ .msg .meta time{margin-left:auto;font-variant-numeric:tabular-nums;font-size:12px}
914
+ .msg .body{margin-top:6px;font-size:14px;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere}
915
+ .msg.sys{background:transparent;border:0;border-left:3px solid var(--line);border-radius:0;padding:3px 12px}
916
+ .msg.sys .meta .label{color:var(--mute);font-weight:400}.msg.sys .body{font-size:13px;color:var(--mute);margin-top:2px}
917
+ .msg.k-merge .label{color:var(--ok)!important}.msg.k-fail .label{color:var(--bad)!important}.msg.k-bug .label,.msg.k-radar .label,.msg.k-flaw .label{color:var(--warn)!important}
918
+ .msg.k-adopt .label{color:var(--ok)!important}
919
+ .msg .re{font-style:italic}
920
+ .msg .body.long{max-height:22em;overflow:auto}
921
+ .tx{margin:10px 0}
922
+ .tx summary{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:baseline}
923
+ .tx summary small{margin:0}
924
+ .turns{display:flex;flex-direction:column;gap:6px;margin-top:10px}
925
+ .turn{font-size:14px;min-width:0}
926
+ .turn.text{background:var(--soft);border-radius:10px;padding:8px 10px;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere}
927
+ .turn.thinking{color:var(--mute);font-style:italic;white-space:pre-wrap;word-break:break-word;border-left:3px solid var(--line);padding-left:10px}
928
+ .turn.tool .call{font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
929
+ .turn.tool .about{font:italic 13px/1.4 ui-sans-serif,system-ui,sans-serif;color:var(--mute)}
930
+ .turn.tool .call b{font-family:ui-sans-serif,system-ui,sans-serif}
931
+ .turn.tool.err .call b{color:var(--bad)}
932
+ .turn pre,.msg pre{margin:4px 0 0;padding:8px 10px;background:var(--soft);border-radius:8px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;max-height:24em;overflow:auto}
933
+ .turn details{border:0;background:none;padding:0;margin:2px 0 0}.turn details summary{font-weight:400;font-size:12px;color:var(--mute)}
934
+ .turn.note,.turn.end{font-size:12px;color:var(--mute)}
935
+ .summary table td{white-space:normal}
936
+ .plan li .badge{margin-left:6px}
937
+ `;
938
+ function filterRules(senders) {
939
+ // Unchecked = that sender's messages (and sessions) hidden. No script: :has() on the body.
940
+ return senders.map((a, i) => `body:has(#f${i}:not(:checked)) [data-from="${esc(a)}"]{display:none}`).join("\n");
941
+ }
942
+ export function renderExportHtml(doc) {
943
+ const r = doc.run;
944
+ const s = doc.summary;
945
+ const res = s.result;
946
+ const chip = (a) => `<span class="who"><i style="background:${colorOf(isCrew(doc, a) ? a : null)}"></i>${esc(a)}</span>`;
947
+ const badge = (text, tone = "") => `<span class="badge ${tone}">${esc(text)}</span>`;
948
+ const card = (title, body) => `<div class="card"><h3>${esc(title)}</h3>${body}</div>`;
949
+ const kpis = [
950
+ { n: String(res.merged), l: "merged", tone: "ok" },
951
+ { n: String(res.failed), l: "failed", tone: res.failed ? "bad" : "" },
952
+ ...(res.merges ? [{ n: `${res.green}/${res.merges}`, l: "merged on green tests", tone: res.green === res.merges ? "ok" : "warn" }] : []),
953
+ { n: fmtDuration(res.ms), l: "start to finish", tone: "" },
954
+ ...(res.tokens ? [{ n: fmtTokens(res.tokens), l: "tokens", tone: "" }] : []),
955
+ ...(doc.level !== "summary" ? [{ n: String(doc.messages.length), l: "messages", tone: "" }] : []),
956
+ ]
957
+ .map((k) => `<div class="kpi ${k.tone}"><b>${esc(k.n)}</b><span>${esc(k.l)}</span></div>`)
958
+ .join("");
959
+ const crew = doc.agents
960
+ .map((a) => `<div class="agent" style="--c:${a.color}"><div class="an"><i></i>${esc(a.id)}</div><div class="as">${esc([`${a.merged} merged`, a.failed ? `${a.failed} failed` : "", a.reviews ? plural(a.reviews, "review") : "", a.qa ? `${a.qa} QA` : "", a.sent ? plural(a.sent, "msg") + " sent" : ""].filter(Boolean).join(" · "))}</div><div class="am">${esc([a.ms ? fmtDuration(a.ms) : "", a.tokens ? fmtTokens(a.tokens) + " tok" : ""].filter(Boolean).join(" · ") || "—")}</div></div>`)
961
+ .join("");
962
+ // ---- summary
963
+ const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
964
+ const plan = s.plan.tasks.length
965
+ ? card(`Plan${s.plan.by ? " · by " + s.plan.by : ""}`, `<ol class="plan">${s.plan.tasks
966
+ .map((t) => `<li><b>${esc(t.title)}</b>${t.doneBy ? badge(`${t.status} · ${t.doneBy}`, t.status === "merged" ? "ok" : "bad") : ""}${t.idea ? ` <small>idea from ${esc(t.idea)}</small>` : ""}${t.deps.length ? ` <small>after ${esc(t.deps.map((d) => "#" + (num.get(d) ?? d)).join(", "))}</small>` : ""}${t.files.length ? `<div class="detail">${esc(t.files.join(", "))}</div>` : ""}</li>`)
967
+ .join("")}</ol>`)
968
+ : "";
969
+ const decisions = s.decisions.length ? card("Decisions", `<ul class="list">${s.decisions.map((d) => `<li>${d.risk ? badge(`risk${d.by ? " · " + d.by : ""}`, "warn") : badge("decided", "ok")} ${esc(d.body)}</li>`).join("")}</ul>`) : "";
970
+ const contract = s.contract
971
+ ? card(`Contract${s.contract.by ? " · by " + s.contract.by : ""}`, `${s.contract.text ? `<details><summary>The contract every task coded to</summary><pre>${esc(s.contract.text)}</pre></details>` : ""}${s.contract.amendments.length ? `<ul class="list">${s.contract.amendments.map((a) => `<li>◆ v${a.version} by ${chip(a.by)} <span class="detail">${esc(a.change)}</span></li>`).join("")}</ul>` : ""}`)
972
+ : "";
973
+ const team = s.teamwork.lines.length ? card("Teamwork", `<table class="team">${s.teamwork.lines.map((l) => `<tr><th>${esc(l.label)}</th><td>${esc(l.text)}</td></tr>`).join("")}</table>`) : "";
974
+ const tiles = s.teamwork.tally.length ? `<div class="tiles">${s.teamwork.tally.map((t) => `<div class="tile"><b>${esc(t.n)}</b><span>${esc(t.label)}</span></div>`).join("")}</div>` : "";
975
+ const cost = s.cost.some((c) => c.tokens || c.ms)
976
+ ? card("Cost", `<table><tr><th>agent</th><th>tokens</th><th>cached</th><th>time</th></tr>${s.cost.map((c) => `<tr><th>${chip(c.agent)}</th><td>${c.tokens ? esc(fmtTokens(c.tokens)) : "—"}</td><td>${c.cached ? esc(fmtTokens(c.cached)) : "—"}</td><td>${c.ms ? esc(fmtDuration(c.ms)) : "—"}</td></tr>`).join("")}<tr><th>all</th><td><b>${esc(fmtTokens(s.cost.reduce((n, c) => n + c.tokens, 0)))}</b></td><td>${esc(fmtTokens(s.cost.reduce((n, c) => n + c.cached, 0)))}</td><td>${esc(fmtDuration(r.ms))}</td></tr></table>`)
977
+ : "";
978
+ const whoTable = doc.agents.length
979
+ ? `<div class="scroll card"><table><tr><th>agent</th><th>merged</th><th>failed</th><th>reviews</th><th>QA</th><th>msgs out/in</th><th>proposals/critiques</th><th>bugs found/fixed</th><th>tool calls</th><th>time</th><th>tokens</th></tr>${doc.agents
980
+ .map((a) => `<tr><th>${chip(a.id)}</th><td>${a.merged}</td><td>${a.failed}</td><td>${a.reviews}</td><td>${a.qa}</td><td>${a.sent}/${a.received}</td><td>${a.proposals}/${a.critiques}</td><td>${a.bugsFound}/${a.bugsFixed}</td><td>${a.toolCalls}</td><td>${a.ms ? esc(fmtDuration(a.ms)) : "—"}</td><td>${a.tokens ? esc(fmtTokens(a.tokens)) : "—"}</td></tr>`)
981
+ .join("")}</table></div>`
982
+ : "";
983
+ // ---- the chat
984
+ const senders = [...new Set([...doc.messages.map((m) => m.from), ...Object.values(doc.transcripts).map((t) => t.agent)])];
985
+ const order = (a) => (isCrew(doc, a) ? 0 : a === "huddle" ? 2 : 1);
986
+ senders.sort((a, b) => order(a) - order(b));
987
+ const filter = doc.level === "summary" || !senders.length
988
+ ? ""
989
+ : `<nav class="filter" aria-label="show messages from"><b>show</b>${senders
990
+ .map((a, i) => `<input type="checkbox" id="f${i}" checked><label for="f${i}" style="--c:${colorOf(isCrew(doc, a) ? a : null)}"><i></i>${esc(a)}</label>`)
991
+ .join("")}<a href="#top">↑ top</a></nav>`;
992
+ const SYS = new Set(["system", "merge", "fail", "qa"]);
993
+ let phase = null;
994
+ const parts = [];
995
+ for (const m of doc.messages) {
996
+ if (m.phase !== phase) {
997
+ if (phase)
998
+ parts.push("</div></section>");
999
+ phase = m.phase;
1000
+ parts.push(`<section class="phase"><h3><span class="tag">${PHASE_NAME[phase]}</span> ${esc(PHASE_SUB[phase])}</h3><div class="chat">`);
1001
+ }
1002
+ const sys = SYS.has(m.kind) && m.from === "huddle";
1003
+ parts.push(`<div class="msg k-${esc(m.kind)}${sys ? " sys" : ""}" id="m${m.i}" data-from="${esc(m.from)}" style="--c:${colorOf(isCrew(doc, m.from) ? m.from : null)}"><div class="meta">${chip(m.from)}${m.to.length ? ` <span class="arrow">→</span> ${m.to.map(chip).join(" ")}` : ""} <span class="label">${esc(m.label)}</span>${m.replyTo ? ` <span class="re">re: ${esc(m.replyTo)}</span>` : ""}<time>${esc(m.t)}</time></div>${m.text ? `<div class="body${m.text.length > 2500 ? " long" : ""}">${esc(m.text)}</div>` : ""}</div>`);
1004
+ }
1005
+ if (phase)
1006
+ parts.push("</div></section>");
1007
+ const chat = doc.level === "summary" ? "" : `<h2 id="chat">Chat <small>${plural(doc.messages.length, "message")} in time order · times from the start</small></h2>${parts.join("") || '<p class="none">nothing was said in this run</p>'}`;
1008
+ // ---- sessions
1009
+ const turnHtml = (t) => {
1010
+ switch (t.kind) {
1011
+ case "prompt":
1012
+ return `<li class="turn note"><details><summary>the prompt Huddle gave it</summary><pre>${esc(t.text)}</pre></details></li>`;
1013
+ case "text":
1014
+ return `<li class="turn text">${esc(t.text)}</li>`;
1015
+ case "thinking":
1016
+ return `<li class="turn thinking">${esc(t.text)}</li>`;
1017
+ case "tool": {
1018
+ const long = (t.output ?? "").split("\n").length > 6 || (t.output ?? "").length > 500;
1019
+ const out = t.output ? (long ? `<details><summary>output · ${plural(t.output.split("\n").length, "line")}</summary><pre>${esc(t.output)}</pre></details>` : `<pre>${esc(t.output)}</pre>`) : "";
1020
+ return `<li class="turn tool${t.error ? " err" : ""}"><div class="call">${t.error ? "✗" : "▸"} <b>${esc(t.tool ?? "tool")}</b>${t.about ? ` <span class="about">${esc(t.about)}</span>` : ""} ${esc(t.input ?? "")}</div>${out}</li>`;
1021
+ }
1022
+ default:
1023
+ return `<li class="turn ${t.kind}">${esc(t.text)}</li>`;
1024
+ }
1025
+ };
1026
+ const sessions = Object.entries(doc.transcripts)
1027
+ .map(([key, t]) => `<details class="tx" data-from="${esc(t.agent)}"><summary>${chip(t.agent)} <span>${esc(t.title)}</span> <small>${esc(key)} · ${t.found ? `${esc(t.format === "raw" ? "plain text" : t.format)} · ${plural(t.turns.length, "step")} · ${Math.max(1, Math.round(t.bytes / 1024))} KB` : "log not found"}</small></summary>${t.spec ? `<details><summary>the task, as given</summary><pre>${esc(t.spec)}</pre></details>` : ""}<ol class="turns">${t.turns.map(turnHtml).join("")}</ol><p class="legend">${esc(t.file)}</p></details>`)
1028
+ .join("");
1029
+ const transcripts = doc.level === "full" ? `<h2 id="sessions">Sessions <small>each agent's own session, per task: what it said, read, ran and wrote</small></h2>${sessions || '<p class="none">no agent sessions in this run</p>'}` : "";
1030
+ return `<!doctype html>
1031
+ <html lang="en">
1032
+ <head>
1033
+ <meta charset="utf-8">
1034
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1035
+ <title>Huddle run ${r.index}</title>
1036
+ <style>
1037
+ ${STORY_CSS}${EXPORT_CSS}${filterRules(senders)}
1038
+ </style>
1039
+ </head>
1040
+ <body>
1041
+ <main id="top">
1042
+ <header class="hero">
1043
+ <div class="brand">⬢ HUDDLE · RUN ${r.index} OF ${r.of}</div>
1044
+ <h1>${esc(r.title || "(no goal)")}</h1>${r.goal.trim() !== r.title ? `<details><summary>The goal</summary><pre style="white-space:pre-wrap;word-break:break-word">${esc(r.goal)}</pre></details>` : ""}
1045
+ <div class="sub">${r.id ? esc(r.id) + " · " : ""}${esc(new Date(r.start).toLocaleString())} · ${esc(doc.agents.map((a) => a.id).join(", ") || "no agents")} · level ${esc(doc.level)}${doc.redacted ? ` · ${esc(plural(doc.redacted, "likely secret"))} redacted` : ""}</div>
1046
+ <div class="kpis">${kpis}</div>
1047
+ ${res.qa ? `<p class="verdict ${res.qa.tone}">${res.qa.tone === "ok" ? "✓" : "!"} ${esc(plural(res.qaRounds, "QA round"))} · ${esc(res.qa.text)}</p>` : ""}
1048
+ </header>
1049
+ ${crew ? `<div class="crew">${crew}</div>` : ""}
1050
+ <h2>Summary</h2>
1051
+ ${tiles}
1052
+ <div class="cards summary">${plan}${decisions}${contract}${team}${cost}</div>
1053
+ ${whoTable}
1054
+ ${filter}
1055
+ ${chat}
1056
+ ${transcripts}
1057
+ <footer>made by huddle export · one file, no network</footer>
1058
+ </main>
1059
+ </body>
1060
+ </html>
1061
+ `;
1062
+ }
1063
+ const isCrew = (doc, a) => doc.agents.some((x) => x.id === a) && !NOT_AGENTS.has(a);
1064
+ /** One row per run: what `huddle runs` prints (and a dashboard can list). */
1065
+ export function runRows(runs) {
1066
+ return runs.map((run) => {
1067
+ const s = buildStory(run, runs.length);
1068
+ const o = outcome(s);
1069
+ return { index: run.index, id: run.id, start: run.start, ms: o.ms, goal: headline(run.goal, 120), agents: s.agents, merged: s.merged, failed: s.failed, tokens: o.tokens, merges: o.merges, green: o.green, qa: o.qa, qaRounds: o.rounds };
1070
+ });
1071
+ }
1072
+ export function renderRuns(rows, width = 100) {
1073
+ if (!rows.length)
1074
+ return C.faint(" no runs yet in this repo — type a goal in `huddle` (or huddle join \"<goal>\") to start one.");
1075
+ const out = [C.violet("RUNS") + C.faint(` · ${plural(rows.length, "run")} in this repo · newest last`), ""];
1076
+ const w = Math.max(40, width - 8);
1077
+ for (const r of rows) {
1078
+ const goal = r.goal.length > w - 2 ? r.goal.slice(0, w - 3) + "…" : r.goal;
1079
+ out.push(` ${C.bold(String(r.index).padStart(3))} ${C.bold(JSON.stringify(goal))}`);
1080
+ const mark = r.failed ? C.yellow("◎") : r.merged ? C.green("✓") : C.faint("·");
1081
+ const bits = [
1082
+ new Date(r.start).toLocaleString(),
1083
+ fmtDuration(r.ms),
1084
+ r.agents.length ? r.agents.map((a) => `${dot(a)} ${a}`).join(" ") : "no agents",
1085
+ `${mark} ${r.merged} merged${r.failed ? `, ${r.failed} failed` : ""}`,
1086
+ r.merges ? `${r.green}/${r.merges} on green tests` : "",
1087
+ r.qa ? r.qa.text.replace(/^QA\b\s*/, "QA ") : "",
1088
+ r.tokens ? `${fmtTokens(r.tokens)} tok` : "",
1089
+ ].filter(Boolean);
1090
+ out.push(` ${C.faint(bits.join(" · "))}`);
1091
+ }
1092
+ out.push("", C.faint(` huddle export <#> the run as a chat · huddle story --run <#> its timeline`));
1093
+ return out.join("\n");
1094
+ }
1095
+ async function openRuns() {
1096
+ const { repoRoot, dbPath } = await import("./app.js");
1097
+ const { isGitRepo } = await import("../sandbox/worktree.js");
1098
+ const root = repoRoot();
1099
+ if (!isGitRepo(root))
1100
+ return { ok: false, why: "not-repo" };
1101
+ if (!fs.existsSync(dbPath()))
1102
+ return { ok: false, why: "no-room" };
1103
+ const { RoomDB } = await import("../room/db.js");
1104
+ const db = new RoomDB(dbPath());
1105
+ try {
1106
+ return { ok: true, runs: splitRuns(db.allEvents()), dbFile: dbPath(), root };
1107
+ }
1108
+ finally {
1109
+ db.close();
1110
+ }
1111
+ }
1112
+ function refuse(r, cmd) {
1113
+ if (r.why === "not-repo") {
1114
+ console.log(C.red("✗ Not a git repo.") + ` \`huddle ${cmd}\` reads the runs recorded in a repo's .huddle/ — cd into one first.`);
1115
+ process.exitCode = 1;
1116
+ }
1117
+ else
1118
+ console.log(C.faint(" no runs yet in this repo — type a goal in `huddle` (or huddle join \"<goal>\") to start one."));
1119
+ }
1120
+ /** `huddle runs` */
1121
+ export async function runRuns(_args) {
1122
+ const r = await openRuns();
1123
+ if (!r.ok)
1124
+ return refuse(r, "runs");
1125
+ console.log(renderRuns(runRows(r.runs), process.stdout.isTTY ? process.stdout.columns || 100 : 120));
1126
+ }
1127
+ /** "Build \"shelf\", a small app." → "build-shelf-a-small-app": at most 40 characters, cut between words. */
1128
+ const slug = (s) => {
1129
+ const all = s
1130
+ .toLowerCase()
1131
+ .normalize("NFKD")
1132
+ .replace(/[^\w\s-]/g, "")
1133
+ .trim()
1134
+ .replace(/[\s_-]+/g, "-");
1135
+ const cut = all.length > 40 ? all.slice(0, 41).replace(/-[^-]*$/, "") : all;
1136
+ return cut.replace(/^-+|-+$/g, "") || "run";
1137
+ };
1138
+ /** `huddle export [<run>|latest] [--format md|html|json] [--out <path>] [--level summary|chat|full] [--no-redact]` */
1139
+ export async function runExport(args) {
1140
+ const takes = new Set(["--format", "-f", "--out", "-o", "--level", "--run"]);
1141
+ const flag = (...names) => {
1142
+ for (const name of names) {
1143
+ const i = args.indexOf(name);
1144
+ if (i >= 0)
1145
+ return args[i + 1] ?? "";
1146
+ const eq = args.find((a) => a.startsWith(name + "="));
1147
+ if (eq)
1148
+ return eq.slice(name.length + 1);
1149
+ }
1150
+ return undefined;
1151
+ };
1152
+ const bad = (msg) => {
1153
+ console.log(C.red("✗ " + msg));
1154
+ process.exitCode = 1;
1155
+ };
1156
+ const positional = args.filter((a, i) => !a.startsWith("-") && !(i > 0 && takes.has(args[i - 1])));
1157
+ const which = flag("--run") ?? positional[0];
1158
+ const level = (flag("--level") ?? "chat");
1159
+ if (!EXPORT_LEVELS.includes(level))
1160
+ return bad(`--level is summary, chat or full (got ${JSON.stringify(level)})`);
1161
+ const out = flag("--out", "-o");
1162
+ if (out === "")
1163
+ return bad("--out needs a path: huddle export --out run.md (or - for the terminal)");
1164
+ const extFormat = out && out !== "-" ? { ".md": "md", ".markdown": "md", ".html": "html", ".htm": "html", ".json": "json" }[path.extname(out).toLowerCase()] : undefined;
1165
+ const format = (flag("--format", "-f") ?? extFormat ?? "md");
1166
+ if (!EXPORT_FORMATS.includes(format))
1167
+ return bad(`--format is md, html or json (got ${JSON.stringify(format)})`);
1168
+ const unknown = args.filter((a, i) => a.startsWith("-") && a !== "-" && !takes.has(a) && ![...takes].some((t) => a.startsWith(t + "=")) && a !== "--no-redact" && !(i > 0 && takes.has(args[i - 1])));
1169
+ if (unknown.length)
1170
+ return bad(`unknown option ${unknown[0]}. huddle export [<run>|latest] [--level summary|chat|full] [--format md|html|json] [--out <path>] [--no-redact]`);
1171
+ const r = await openRuns();
1172
+ if (!r.ok)
1173
+ return refuse(r, "export");
1174
+ if (!r.runs.length)
1175
+ return refuse({ ok: false, why: "no-room" }, "export");
1176
+ const run = findRun(r.runs, which);
1177
+ if (!run)
1178
+ return bad(`no run ${which}. \`huddle runs\` lists them (1–${r.runs.length}, or latest).`);
1179
+ const huddle = path.dirname(r.dbFile);
1180
+ const doc = buildExport(run, r.runs.length, { level, redact: !args.includes("--no-redact"), logsDir: path.join(huddle, "logs"), repoRoot: r.root });
1181
+ const text = renderExport(doc, format);
1182
+ if (out === "-")
1183
+ return void process.stdout.write(text);
1184
+ const name = `run-${run.index}-${slug(headline(run.goal))}${level === "chat" ? "" : "-" + level}.${format}`;
1185
+ let file = out ? path.resolve(out) : path.join(huddle, "exports", name);
1186
+ if (out && (out.endsWith("/") || out.endsWith(path.sep) || (fs.existsSync(file) && fs.statSync(file).isDirectory())))
1187
+ file = path.join(file, name);
1188
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1189
+ fs.writeFileSync(file, text);
1190
+ const sessions = Object.keys(doc.transcripts).length;
1191
+ console.log(`${C.green("✓")} wrote ${file}` +
1192
+ C.faint(` · run ${run.index} · ${level}${level !== "summary" ? ` · ${plural(doc.messages.length, "message")}` : ""}${level === "full" ? ` · ${plural(sessions, "session")}` : ""}${doc.redacted ? ` · ${plural(doc.redacted, "likely secret")} redacted (--no-redact keeps them)` : ""}`));
1193
+ }
1194
+ //# sourceMappingURL=export.js.map