huddle-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
package/dist/ui/story.js
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
// `huddle story` — one place to SEE a run's collaboration, after the fact.
|
|
2
|
+
//
|
|
3
|
+
// Everything comes from the Room db's event table (.huddle/huddle.db): the plan
|
|
4
|
+
// (task.added + plan.made), the scheduler's own events (persisted as run.*),
|
|
5
|
+
// agents' messages, questions and answers, announced plans, shared memory,
|
|
6
|
+
// reviews, merges — and whatever newer phases write (council.*, takeover,
|
|
7
|
+
// split, qa.*). Known types get a sentence; any other type is still shown,
|
|
8
|
+
// generically, with its fields, so nothing a run recorded is ever lost.
|
|
9
|
+
//
|
|
10
|
+
// huddle story the latest run, as a timeline + who-did-what
|
|
11
|
+
// huddle story --list every run in this repo's room
|
|
12
|
+
// huddle story --run <n|id> one run (number from --list, or session id)
|
|
13
|
+
// huddle story --html out.html a self-contained static report (lanes, threads)
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { C, dot, pad, AGENT_COLOR } from "./term.js";
|
|
17
|
+
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
18
|
+
import { isHelpOffer } from "../room/progress.js";
|
|
19
|
+
/** The scheduler's own event types, persisted as "run.<type>" (scheduler.ts). */
|
|
20
|
+
const CORE_RUN = new Set(["start", "declined", "tokens", "review", "reviewed", "merged", "failed", "stuck", "conflict", "resolved", "handoff"]);
|
|
21
|
+
const NOT_AGENTS = new Set(["huddle", "room", "all", "you", "council", ""]);
|
|
22
|
+
function parse(e) {
|
|
23
|
+
let p = {};
|
|
24
|
+
try {
|
|
25
|
+
const v = JSON.parse(e.payload || "{}");
|
|
26
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
27
|
+
p = v;
|
|
28
|
+
else
|
|
29
|
+
p = { value: v };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
p = { payload: e.payload };
|
|
33
|
+
}
|
|
34
|
+
let type = e.type;
|
|
35
|
+
if (type.startsWith("run.") && !CORE_RUN.has(type.slice(4)))
|
|
36
|
+
type = type.slice(4);
|
|
37
|
+
if (type !== e.type && p.type === e.type.slice(4))
|
|
38
|
+
delete p.type;
|
|
39
|
+
if (e.type.startsWith("run."))
|
|
40
|
+
delete p.type;
|
|
41
|
+
return { ts: e.ts, type, agent: e.agent_id, task: e.task_id, p };
|
|
42
|
+
}
|
|
43
|
+
const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
44
|
+
/** First non-empty field among `keys`. */
|
|
45
|
+
const pick = (p, ...keys) => {
|
|
46
|
+
for (const k of keys) {
|
|
47
|
+
const v = p[k];
|
|
48
|
+
if (v !== undefined && v !== null && str(v).trim())
|
|
49
|
+
return str(v);
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
};
|
|
53
|
+
const oneLine = (s, n = 140) => {
|
|
54
|
+
const l = s.replace(/\s+/g, " ").trim();
|
|
55
|
+
return l.length <= n ? l : l.slice(0, n - 1) + "…";
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A goal as a headline. Found on the capstone: a 3.5 KB project brief was the
|
|
59
|
+
* STORY line (and the list row, and the page's <h1>), escaped quotes and all.
|
|
60
|
+
* Its first line, capped; the full brief stays in the HTML under "The goal".
|
|
61
|
+
*/
|
|
62
|
+
export const goalTitle = (goal, n = 160) => oneLine(goal.split(/\n/)[0] ?? "", n);
|
|
63
|
+
/**
|
|
64
|
+
* "(didn't write it)" only when that's true. Found live (capstone round 3):
|
|
65
|
+
* every agent had written code, QA said "checking anyway", and the story still
|
|
66
|
+
* claimed fresh eyes. Older runs (no `authors` on qa.round) keep the old line.
|
|
67
|
+
*/
|
|
68
|
+
function qaEyes(checkers, authors) {
|
|
69
|
+
if (authors === undefined)
|
|
70
|
+
return "(didn't write it)";
|
|
71
|
+
const wrote = new Set(list(authors));
|
|
72
|
+
const mine = checkers.filter((a) => wrote.has(a));
|
|
73
|
+
if (!mine.length)
|
|
74
|
+
return "(didn't write it)";
|
|
75
|
+
return mine.length === checkers.length ? "(wrote some of it too — checking anyway)" : `(${mine.join(", ")} wrote some of it too)`;
|
|
76
|
+
}
|
|
77
|
+
const list = (v) => {
|
|
78
|
+
if (Array.isArray(v))
|
|
79
|
+
return v.map(str);
|
|
80
|
+
if (typeof v === "string") {
|
|
81
|
+
try {
|
|
82
|
+
const j = JSON.parse(v);
|
|
83
|
+
if (Array.isArray(j))
|
|
84
|
+
return j.map(str);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* plain string */
|
|
88
|
+
}
|
|
89
|
+
return v ? [v] : [];
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Cut the event log into runs. A run starts at the board.cleared just before
|
|
95
|
+
* its session.created (one goal = one board); council.* events written before
|
|
96
|
+
* that board was cleared belong to the run they designed. A room with no goals
|
|
97
|
+
* (attach mode) is one run.
|
|
98
|
+
*/
|
|
99
|
+
export function splitRuns(raw) {
|
|
100
|
+
const evs = raw.map(parse);
|
|
101
|
+
const anchors = evs.map((e, i) => (e.type === "session.created" ? i : -1)).filter((i) => i >= 0);
|
|
102
|
+
const mk = (index, events, s) => ({
|
|
103
|
+
index,
|
|
104
|
+
id: s ? str(s.p.id) || null : null,
|
|
105
|
+
goal: s ? str(s.p.goal) : "(no goal — attach sessions)",
|
|
106
|
+
start: events[0]?.ts ?? 0,
|
|
107
|
+
end: events.at(-1)?.ts ?? 0,
|
|
108
|
+
events,
|
|
109
|
+
});
|
|
110
|
+
if (!anchors.length)
|
|
111
|
+
return evs.length ? [mk(1, evs)] : [];
|
|
112
|
+
const starts = anchors.map((a, k) => {
|
|
113
|
+
const floor = k ? anchors[k - 1] + 1 : 0;
|
|
114
|
+
for (let i = a; i >= floor; i--)
|
|
115
|
+
if (evs[i].type === "board.cleared")
|
|
116
|
+
return i;
|
|
117
|
+
return a;
|
|
118
|
+
});
|
|
119
|
+
const chunks = starts.map((s, k) => evs.slice(s, k + 1 < starts.length ? starts[k + 1] : evs.length));
|
|
120
|
+
// The design talk happens before the board is cleared: move it forward.
|
|
121
|
+
const isDesign = (e) => e.type.startsWith("council.");
|
|
122
|
+
const before = evs.slice(0, starts[0]).filter(isDesign);
|
|
123
|
+
chunks[0] = [...before, ...chunks[0]];
|
|
124
|
+
for (let k = 1; k < chunks.length; k++) {
|
|
125
|
+
const prev = chunks[k - 1];
|
|
126
|
+
let cut = prev.length;
|
|
127
|
+
while (cut > 0 && isDesign(prev[cut - 1]))
|
|
128
|
+
cut--;
|
|
129
|
+
if (cut < prev.length) {
|
|
130
|
+
chunks[k] = [...prev.slice(cut), ...chunks[k]];
|
|
131
|
+
chunks[k - 1] = prev.slice(0, cut);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return chunks.map((c, k) => mk(k + 1, c, evs[anchors[k]]));
|
|
135
|
+
}
|
|
136
|
+
/** Pick a run: latest by default, else its number (from --list) or session id. */
|
|
137
|
+
export function pickRun(runs, which) {
|
|
138
|
+
if (!which)
|
|
139
|
+
return runs.at(-1);
|
|
140
|
+
const n = Number(which);
|
|
141
|
+
if (Number.isInteger(n))
|
|
142
|
+
return n > 0 ? runs.find((r) => r.index === n) : runs.at(n - 1); // 0 = latest, -1 = the one before
|
|
143
|
+
return runs.find((r) => r.id === which);
|
|
144
|
+
}
|
|
145
|
+
const newStats = () => ({
|
|
146
|
+
merged: 0,
|
|
147
|
+
failed: 0,
|
|
148
|
+
reviews: 0,
|
|
149
|
+
changesAsked: 0,
|
|
150
|
+
sent: 0,
|
|
151
|
+
received: 0,
|
|
152
|
+
proposals: 0,
|
|
153
|
+
critiques: 0,
|
|
154
|
+
bugsFound: 0,
|
|
155
|
+
bugsFixed: 0,
|
|
156
|
+
takeoversIn: 0,
|
|
157
|
+
takeoversOut: 0,
|
|
158
|
+
toolCalls: 0,
|
|
159
|
+
ms: 0,
|
|
160
|
+
tokens: 0,
|
|
161
|
+
cached: 0,
|
|
162
|
+
});
|
|
163
|
+
export function buildStory(run, runs = 1) {
|
|
164
|
+
const titles = new Map();
|
|
165
|
+
const owner = new Map(); // task -> agent on it now
|
|
166
|
+
const agents = [];
|
|
167
|
+
const stats = new Map();
|
|
168
|
+
const talk = new Map();
|
|
169
|
+
const threads = new Map();
|
|
170
|
+
const items = [];
|
|
171
|
+
const design = [];
|
|
172
|
+
const spans = [];
|
|
173
|
+
const plan = { tasks: [] };
|
|
174
|
+
const open = new Map(); // agent -> its open span
|
|
175
|
+
const councilTasks = [];
|
|
176
|
+
let merged = 0;
|
|
177
|
+
let failed = 0;
|
|
178
|
+
// The brain that settles a council plan (a label like "claude · sonnet") is not an agent on the crew.
|
|
179
|
+
const working = new Set(run.events.filter((e) => e.type === "run.start" || e.type === "agent.joined").map((e) => e.agent));
|
|
180
|
+
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
|
+
const isAgent = (a) => !!a && !NOT_AGENTS.has(a) && !brains.has(a);
|
|
182
|
+
const see = (a) => {
|
|
183
|
+
if (!isAgent(a))
|
|
184
|
+
return;
|
|
185
|
+
if (!agents.includes(a))
|
|
186
|
+
agents.push(a);
|
|
187
|
+
if (!stats.has(a))
|
|
188
|
+
stats.set(a, newStats());
|
|
189
|
+
};
|
|
190
|
+
const st = (a) => {
|
|
191
|
+
see(a);
|
|
192
|
+
return (isAgent(a) && stats.get(a)) || newStats();
|
|
193
|
+
};
|
|
194
|
+
const talked = (from, to) => {
|
|
195
|
+
if (!isAgent(from) || !isAgent(to) || from === to)
|
|
196
|
+
return;
|
|
197
|
+
see(from);
|
|
198
|
+
see(to);
|
|
199
|
+
const row = talk.get(from) ?? new Map();
|
|
200
|
+
row.set(to, (row.get(to) ?? 0) + 1);
|
|
201
|
+
talk.set(from, row);
|
|
202
|
+
};
|
|
203
|
+
const thread = (it) => {
|
|
204
|
+
if (!it.agent || !it.to)
|
|
205
|
+
return;
|
|
206
|
+
const key = [it.agent, it.to].sort().join(" ↔ ");
|
|
207
|
+
threads.set(key, [...(threads.get(key) ?? []), it]);
|
|
208
|
+
};
|
|
209
|
+
const title = (task, p) => pick(p ?? {}, "title") || (task ? titles.get(task) ?? task : "");
|
|
210
|
+
const q = (s) => (s ? `“${s}”` : "");
|
|
211
|
+
const close = (agent, ts, outcome) => {
|
|
212
|
+
const s = open.get(agent);
|
|
213
|
+
if (!s)
|
|
214
|
+
return;
|
|
215
|
+
s.to = Math.max(ts, s.from);
|
|
216
|
+
if (outcome)
|
|
217
|
+
s.outcome = outcome;
|
|
218
|
+
open.delete(agent);
|
|
219
|
+
};
|
|
220
|
+
const begin = (s) => {
|
|
221
|
+
close(s.agent, s.from);
|
|
222
|
+
const span = { ...s, to: run.end };
|
|
223
|
+
spans.push(span);
|
|
224
|
+
open.set(s.agent, span);
|
|
225
|
+
};
|
|
226
|
+
const add = (it) => {
|
|
227
|
+
see(it.agent);
|
|
228
|
+
(it.kind === "design" ? design : items).push(it);
|
|
229
|
+
};
|
|
230
|
+
const hasRun = run.events.some((e) => e.type.startsWith("run."));
|
|
231
|
+
// The scheduler's "takeover" and the Room's "task.takeover" describe the same handover;
|
|
232
|
+
// when the Room's is there it tells the story and the scheduler's only carries the cost.
|
|
233
|
+
const roomTakeovers = run.events.some((e) => e.type === "task.takeover");
|
|
234
|
+
const firstWork = run.events.find((e) => e.type === "run.start" || e.type === "task.claimed")?.ts ?? Infinity;
|
|
235
|
+
const recent = [];
|
|
236
|
+
for (const e of run.events) {
|
|
237
|
+
const p = e.p;
|
|
238
|
+
const ts = e.ts;
|
|
239
|
+
// The same phase may be written twice (a Room event and a scheduler event): show it once.
|
|
240
|
+
if (/^(council\.|takeover|split|task\.takeover|task\.split|qa\.)/.test(e.type)) {
|
|
241
|
+
// round + agents: two QA rounds a second apart are two rounds (mock capstone: round 2's line vanished).
|
|
242
|
+
const sig = [e.type, e.task, pick(p, "from", "agent", "by") || e.agent, pick(p, "to"), oneLine(pick(p, "title", "summary", "body", "reason"), 60), p.round === undefined ? "" : str(p.round), list(p.agents).join(",")].join("|");
|
|
243
|
+
if (recent.some((r) => r.sig === sig && ts - r.ts < 2000))
|
|
244
|
+
continue;
|
|
245
|
+
recent.push({ sig, ts });
|
|
246
|
+
}
|
|
247
|
+
switch (e.type) {
|
|
248
|
+
case "session.created":
|
|
249
|
+
case "board.cleared":
|
|
250
|
+
case "agent.status":
|
|
251
|
+
case "handoff.ready":
|
|
252
|
+
case "run.tokens":
|
|
253
|
+
case "run.handoff": // the Room's own "handoff" event says the same, with the files
|
|
254
|
+
break;
|
|
255
|
+
case "agent.joined":
|
|
256
|
+
see(e.agent ?? pick(p, "id"));
|
|
257
|
+
break;
|
|
258
|
+
case "plug.call":
|
|
259
|
+
st(e.agent).toolCalls++;
|
|
260
|
+
break;
|
|
261
|
+
case "plan.made":
|
|
262
|
+
plan.by = pick(p, "by") || plan.by;
|
|
263
|
+
plan.note = pick(p, "note") || plan.note;
|
|
264
|
+
break;
|
|
265
|
+
case "task.added": {
|
|
266
|
+
const id = pick(p, "id") || e.task || "";
|
|
267
|
+
const t = pick(p, "title");
|
|
268
|
+
titles.set(id, t);
|
|
269
|
+
if (ts <= firstWork) {
|
|
270
|
+
plan.tasks.push({
|
|
271
|
+
id,
|
|
272
|
+
title: t,
|
|
273
|
+
deps: list(p.depends_on),
|
|
274
|
+
prefers: list(p.prefers),
|
|
275
|
+
files: list(p.files),
|
|
276
|
+
by: pick(p, "proposed_by", "by", "author") || undefined,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const by = pick(p, "proposed_by", "by", "author", "filed_by");
|
|
281
|
+
add({ ts, kind: "task", type: e.type, agent: isAgent(by) ? by : null, text: `new task on the board: ${q(t)}`, detail: oneLine(pick(p, "spec"), 200) });
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "task.claimed": {
|
|
286
|
+
if (e.task && e.agent)
|
|
287
|
+
owner.set(e.task, e.agent);
|
|
288
|
+
see(e.agent);
|
|
289
|
+
const viaScheduler = run.events.some((x) => x.type === "run.start" && x.task === e.task && x.agent === e.agent && Math.abs(x.ts - ts) < 3000);
|
|
290
|
+
if (!viaScheduler)
|
|
291
|
+
add({ ts, kind: "task", type: e.type, agent: e.agent, text: `claims ${q(title(e.task))}` });
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case "task.progress": {
|
|
295
|
+
const note = pick(p, "note");
|
|
296
|
+
if (!note)
|
|
297
|
+
break;
|
|
298
|
+
add({ ts, kind: "progress", type: e.type, agent: (e.task && owner.get(e.task)) || null, text: `${String(p.pct ?? "?").padStart(3)}% ${oneLine(note)}`, detail: q(title(e.task)) });
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "task.status": {
|
|
302
|
+
if (hasRun)
|
|
303
|
+
break; // the scheduler's run.* events tell it better
|
|
304
|
+
const s = pick(p, "status");
|
|
305
|
+
if (["merged", "failed", "needs_you"].includes(s)) {
|
|
306
|
+
const who = (e.task && owner.get(e.task)) || null;
|
|
307
|
+
if (s === "merged")
|
|
308
|
+
merged++;
|
|
309
|
+
else
|
|
310
|
+
failed++;
|
|
311
|
+
add({ ts, kind: s === "merged" ? "merge" : "fail", type: e.type, agent: who, text: `${s === "merged" ? "✓ merged" : s === "failed" ? "✗ failed" : "! needs you"} ${q(title(e.task))}` });
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "plan.announced": {
|
|
316
|
+
const touches = list(p.touches);
|
|
317
|
+
const hits = Array.isArray(p.conflicts) ? p.conflicts : [];
|
|
318
|
+
add({
|
|
319
|
+
ts,
|
|
320
|
+
kind: "announce",
|
|
321
|
+
type: e.type,
|
|
322
|
+
agent: e.agent,
|
|
323
|
+
text: `announces: ${oneLine(pick(p, "intent"), 110)}${touches.length ? ` · touches ${touches.slice(0, 5).join(", ")}${touches.length > 5 ? "…" : ""}` : ""}`,
|
|
324
|
+
detail: hits.length ? `overlaps: ${hits.map((h) => `${str(h.path)} (held by ${str(h.held_by)})`).join(", ")}` : undefined,
|
|
325
|
+
});
|
|
326
|
+
see(e.agent);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "memory.added": {
|
|
330
|
+
const author = pick(p, "author") || e.agent || "";
|
|
331
|
+
const body = pick(p, "body");
|
|
332
|
+
if (author === "huddle") {
|
|
333
|
+
// Mostly the scheduler restating what run.* says; the red gate is news.
|
|
334
|
+
if (/red test gate/.test(body))
|
|
335
|
+
add({ ts, kind: "fail", type: e.type, agent: null, text: oneLine(body) });
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
if (/^Review of /.test(body))
|
|
339
|
+
break; // the verdict is the review item
|
|
340
|
+
const kind = pick(p, "kind") || "note";
|
|
341
|
+
const done = /^Task \S+ done:\s*/.exec(body);
|
|
342
|
+
add({
|
|
343
|
+
ts,
|
|
344
|
+
kind: done ? "task" : "memory",
|
|
345
|
+
type: e.type,
|
|
346
|
+
agent: author,
|
|
347
|
+
text: done ? `done: ${oneLine(body.slice(done[0].length))}` : `${kind === "decision" && /contract/i.test(body) ? "contract" : kind}: ${oneLine(body)}`,
|
|
348
|
+
detail: pick(p, "why") ? `why: ${oneLine(pick(p, "why"), 200)}` : undefined,
|
|
349
|
+
});
|
|
350
|
+
see(author);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "agent.message":
|
|
354
|
+
case "note": {
|
|
355
|
+
const from = pick(p, "from") || e.agent || "?";
|
|
356
|
+
const to = pick(p, "to") || "?";
|
|
357
|
+
const kind = pick(p, "kind") || "note";
|
|
358
|
+
// Huddle's automatic help offer, sent in a free agent's name: a board event,
|
|
359
|
+
// not a message the agent wrote. Found live (capstone round 2): 6 offers
|
|
360
|
+
// and no real message, yet the table said claude sent 3 messages.
|
|
361
|
+
if (isHelpOffer(from, pick(p, "body"))) {
|
|
362
|
+
const t = /your task "(.*)" \(/.exec(pick(p, "body"))?.[1];
|
|
363
|
+
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)` });
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
const it = { ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "body"), 400), detail: kind === "note" ? undefined : kind };
|
|
367
|
+
add(it);
|
|
368
|
+
thread(it);
|
|
369
|
+
st(from).sent++;
|
|
370
|
+
st(to).received++;
|
|
371
|
+
talked(from, to);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case "ask": {
|
|
375
|
+
if (p.routed)
|
|
376
|
+
break; // it's also an agent.message (kind question)
|
|
377
|
+
const from = pick(p, "from") || e.agent || "?";
|
|
378
|
+
const to = pick(p, "to") || "room";
|
|
379
|
+
const it = { ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "question"), 400), detail: "question" };
|
|
380
|
+
add(it);
|
|
381
|
+
st(from).sent++;
|
|
382
|
+
if (isAgent(to)) {
|
|
383
|
+
st(to).received++;
|
|
384
|
+
talked(from, to);
|
|
385
|
+
thread(it);
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case "answer": {
|
|
390
|
+
if (pick(p, "source") === "teammate")
|
|
391
|
+
break; // it's also an agent.message (kind answer)
|
|
392
|
+
const from = pick(p, "from") || "room";
|
|
393
|
+
const to = pick(p, "to");
|
|
394
|
+
add({ ts, kind: "message", type: e.type, agent: from, to, text: oneLine(pick(p, "answer"), 400), detail: `answer · ${pick(p, "source") || "shared memory"}` });
|
|
395
|
+
if (isAgent(to))
|
|
396
|
+
st(to).received++;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "handoff": {
|
|
400
|
+
const from = pick(p, "from") || e.agent;
|
|
401
|
+
const to = pick(p, "to");
|
|
402
|
+
const files = list(p.files);
|
|
403
|
+
add({ ts, kind: "handoff", type: e.type, agent: from, to: to && to !== from ? to : null, text: `hands off ${q(pick(p, "title"))} · ${files.length} file(s), ${list(p.symbols).length} export(s)`, detail: files.slice(0, 6).join(", ") || undefined });
|
|
404
|
+
talked(from, to);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case "message":
|
|
408
|
+
add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "from")} (person): ${oneLine(pick(p, "body"), 300)}` });
|
|
409
|
+
break;
|
|
410
|
+
case "member.joined":
|
|
411
|
+
add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "github") ? "@" + pick(p, "github") : pick(p, "name")} joined the room as ${pick(p, "role") || "member"}` });
|
|
412
|
+
break;
|
|
413
|
+
case "member.left":
|
|
414
|
+
add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "name")} ${pick(p, "reason") || "left"}` });
|
|
415
|
+
break;
|
|
416
|
+
case "crew.updated":
|
|
417
|
+
add({ ts, kind: "team", type: e.type, agent: null, text: `${pick(p, "member")}'s crew: ${list(p.agents).join(", ") || "(none)"}` });
|
|
418
|
+
break;
|
|
419
|
+
case "assign":
|
|
420
|
+
add({ ts, kind: "task", type: e.type, agent: e.agent, text: `offered ${q(pick(p.task ?? {}, "title") || title(e.task))} on ${pick(p, "worker") || "a teammate"}'s machine` });
|
|
421
|
+
break;
|
|
422
|
+
case "live.joined":
|
|
423
|
+
see(e.agent);
|
|
424
|
+
add({ ts, kind: "team", type: e.type, agent: e.agent, text: `joined live${pick(p, "branch") ? " on " + pick(p, "branch") : ""}` });
|
|
425
|
+
break;
|
|
426
|
+
case "live.left":
|
|
427
|
+
add({ ts, kind: "team", type: e.type, agent: e.agent, text: `left${pick(p, "why") ? " · " + pick(p, "why") : ""}` });
|
|
428
|
+
break;
|
|
429
|
+
case "live.released":
|
|
430
|
+
add({ ts, kind: "announce", type: e.type, agent: e.agent, text: `released ${list(p.paths).join(", ")}` });
|
|
431
|
+
break;
|
|
432
|
+
// ---- the scheduler ----
|
|
433
|
+
case "run.start": {
|
|
434
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
435
|
+
if (e.task)
|
|
436
|
+
owner.set(e.task, a);
|
|
437
|
+
const t = title(e.task, p);
|
|
438
|
+
begin({ agent: a, from: ts, kind: "task", label: (p.fixing ? "fix: " : "") + t });
|
|
439
|
+
add({ ts, kind: "task", type: e.type, agent: a, text: `${p.fixing ? "▸ fix round on" : "▸ starts"} ${q(t)}${p.remote ? ` · on ${pick(p, "remote")}'s machine` : ""}` });
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "run.declined":
|
|
443
|
+
add({ ts, kind: "task", type: e.type, agent: pick(p, "agent") || e.agent, text: `declined ${q(title(e.task, p))} · back on the board` });
|
|
444
|
+
break;
|
|
445
|
+
case "run.review": {
|
|
446
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
447
|
+
const author = pick(p, "author");
|
|
448
|
+
close(author, ts, undefined);
|
|
449
|
+
begin({ agent: a, from: ts, kind: "review", label: `review ${author}` });
|
|
450
|
+
add({ ts, kind: "review", type: e.type, agent: a, to: author, text: `◎ reviews ${author}'s ${q(title(e.task, p))}` });
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case "run.reviewed": {
|
|
454
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
455
|
+
const author = pick(p, "author");
|
|
456
|
+
const s = st(a);
|
|
457
|
+
s.reviews++;
|
|
458
|
+
s.ms += Number(p.ms) || 0;
|
|
459
|
+
if (p.approve === false)
|
|
460
|
+
s.changesAsked++;
|
|
461
|
+
close(a, ts, p.skipped ? "warn" : p.approve === false ? "bad" : "ok");
|
|
462
|
+
const notes = pick(p, "notes");
|
|
463
|
+
const it = {
|
|
464
|
+
ts,
|
|
465
|
+
kind: "review",
|
|
466
|
+
type: e.type,
|
|
467
|
+
agent: a,
|
|
468
|
+
to: author,
|
|
469
|
+
text: p.skipped ? `○ review skipped (${oneLine(notes, 60)})` : p.approve === false ? `✎ asks ${author} for changes` : `✓ approves ${author}'s work`,
|
|
470
|
+
detail: p.approve === false ? notes : undefined,
|
|
471
|
+
};
|
|
472
|
+
add(it);
|
|
473
|
+
if (!p.skipped) {
|
|
474
|
+
talked(a, author);
|
|
475
|
+
thread(it);
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case "run.merged": {
|
|
480
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
481
|
+
const s = st(a);
|
|
482
|
+
s.merged++;
|
|
483
|
+
s.ms += Number(p.ms) || 0;
|
|
484
|
+
s.tokens += Number(p.tokens) || 0;
|
|
485
|
+
s.cached += Number(p.cached) || 0;
|
|
486
|
+
merged++;
|
|
487
|
+
if (/^Fix: /.test(title(e.task, p)))
|
|
488
|
+
s.bugsFixed++; // a QA bug's fix task landed
|
|
489
|
+
close(a, ts, "ok");
|
|
490
|
+
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
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
case "run.failed": {
|
|
494
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
495
|
+
const s = st(a);
|
|
496
|
+
s.failed++;
|
|
497
|
+
s.ms += Number(p.ms) || 0;
|
|
498
|
+
s.tokens += Number(p.tokens) || 0;
|
|
499
|
+
s.cached += Number(p.cached) || 0;
|
|
500
|
+
failed++;
|
|
501
|
+
close(a, ts, "bad");
|
|
502
|
+
add({ ts, kind: "fail", type: e.type, agent: a, text: `${p.needsYou ? "! needs you:" : "✗ failed"} ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 300) });
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "run.stuck":
|
|
506
|
+
add({ ts, kind: "fail", type: e.type, agent: null, text: `! blocked by failures: ${list(p.blocked).join(" · ")}` });
|
|
507
|
+
break;
|
|
508
|
+
case "run.conflict": {
|
|
509
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
510
|
+
const author = pick(p, "author");
|
|
511
|
+
begin({ agent: a, from: ts, kind: "conflict", label: `resolve ${title(e.task, p)}` });
|
|
512
|
+
add({ ts, kind: "conflict", type: e.type, agent: a, to: author !== a ? author : null, text: `⇄ resolves a conflict in ${author === a ? "its own" : author + "'s"} ${q(title(e.task, p))} · ${list(p.files).join(", ")} · try ${str(p.attempt)}` });
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case "run.resolved": {
|
|
516
|
+
const a = pick(p, "agent") || e.agent || "?";
|
|
517
|
+
st(a).ms += Number(p.ms) || 0;
|
|
518
|
+
close(a, ts, p.ok ? "ok" : "bad");
|
|
519
|
+
add({ ts, kind: "conflict", type: e.type, agent: a, text: `${p.ok ? "✓ resolved" : "✗ resolution rejected"} ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 200) });
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
// ---- newer phases: design council, takeovers, QA ----
|
|
523
|
+
case "council.proposal": {
|
|
524
|
+
const a = pick(p, "agent", "from", "by") || e.agent;
|
|
525
|
+
st(a).proposals++;
|
|
526
|
+
add({ ts, kind: "design", type: e.type, agent: a, text: `proposes: ${oneLine(pick(p, "summary", "title", "proposal", "body", "text", "plan"), 300)}`, detail: oneLine(pick(p, "risks", "details", "why"), 300) || undefined });
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "council.critique": {
|
|
530
|
+
const a = pick(p, "agent", "from", "by", "critic") || e.agent;
|
|
531
|
+
const of = pick(p, "of", "target", "to", "author");
|
|
532
|
+
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)}` };
|
|
534
|
+
add(it);
|
|
535
|
+
talked(a, of);
|
|
536
|
+
thread(it);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case "council.plan": {
|
|
540
|
+
const by = pick(p, "by", "agent", "merged_by") || e.agent;
|
|
541
|
+
plan.by = plan.by ?? (by ? `${by} (council)` : "council");
|
|
542
|
+
const tasks = Array.isArray(p.tasks) ? p.tasks : [];
|
|
543
|
+
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) : ""}` });
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case "task.takeover": {
|
|
548
|
+
const from = pick(p, "from");
|
|
549
|
+
const to = pick(p, "to") || e.agent;
|
|
550
|
+
if (e.task && to)
|
|
551
|
+
owner.set(e.task, to);
|
|
552
|
+
talked(from, to);
|
|
553
|
+
if (pick(p, "kind") === "split") {
|
|
554
|
+
add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `✂ takes part of ${from}'s task: ${q(title(e.task, p))}`, detail: oneLine(pick(p, "reason"), 200) || undefined });
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
st(from).takeoversOut++;
|
|
558
|
+
st(to).takeoversIn++;
|
|
559
|
+
add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `↻ takes over ${q(title(e.task, p))} from ${from}, on the same branch`, detail: oneLine(pick(p, "reason"), 200) || undefined });
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case "task.split":
|
|
563
|
+
add({ ts, kind: "takeover", type: e.type, agent: pick(p, "from") || e.agent, text: `✂ splits ${q(pick(p, "title"))} off ${q(pick(p, "parent_title"))} for a free agent`, detail: list(p.files).join(", ") || undefined });
|
|
564
|
+
break;
|
|
565
|
+
case "offer": // the help offer itself is an agent.message in the busy agent's inbox
|
|
566
|
+
break;
|
|
567
|
+
case "takeover": {
|
|
568
|
+
if (roomTakeovers) {
|
|
569
|
+
// The first agent's time and tokens on the task it handed over.
|
|
570
|
+
const s = st(pick(p, "from"));
|
|
571
|
+
s.ms += Number(p.ms) || 0;
|
|
572
|
+
s.tokens += Number(p.tokens) || 0;
|
|
573
|
+
s.cached += Number(p.cached) || 0;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
const from = pick(p, "from", "was", "previous");
|
|
577
|
+
const to = pick(p, "to", "agent", "by") || e.agent;
|
|
578
|
+
st(from).takeoversOut++;
|
|
579
|
+
st(to).takeoversIn++;
|
|
580
|
+
if (e.task && to)
|
|
581
|
+
owner.set(e.task, to);
|
|
582
|
+
add({ ts, kind: "takeover", type: e.type, agent: to, to: from || null, text: `↻ takes over ${q(title(e.task, p))}${from ? ` from ${from}` : ""}`, detail: oneLine(pick(p, "reason", "why"), 200) || undefined });
|
|
583
|
+
talked(from, to);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case "split": {
|
|
587
|
+
const from = pick(p, "from", "agent", "by") || e.agent;
|
|
588
|
+
const parts = Array.isArray(p.into) ? p.into : Array.isArray(p.parts) ? p.parts : Array.isArray(p.tasks) ? p.tasks : [];
|
|
589
|
+
const names = parts.map((x) => (typeof x === "string" ? x : pick(x ?? {}, "title", "id"))).filter(Boolean);
|
|
590
|
+
const to = list(p.to ?? p.agents);
|
|
591
|
+
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
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
case "council.skipped": {
|
|
595
|
+
const a = pick(p, "agent") || e.agent;
|
|
596
|
+
add({ ts, kind: "design", type: e.type, agent: a, text: `${pick(p, "round") || "council"} skipped (${pick(p, "reason") || "no answer"})` });
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
case "qa.done": {
|
|
600
|
+
const a = e.agent || pick(p, "agent");
|
|
601
|
+
if (isAgent(a))
|
|
602
|
+
close(a, ts, p.ok === false ? "bad" : "ok");
|
|
603
|
+
add({ ts, kind: "qa", type: e.type, agent: a, text: `${p.ok === false ? "! QA check didn't finish" : "✓ QA check done"} · ${str(p.bugs ?? 0)} bug(s) filed`, detail: p.ms ? fmtDuration(Number(p.ms)) : undefined });
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case "qa.duplicate":
|
|
607
|
+
add({ ts, kind: "qa", type: e.type, agent: pick(p, "by") || e.agent, text: `= ${q(pick(p, "title"))} is a duplicate of ${q(title(pick(p, "of")))} · not filed again` });
|
|
608
|
+
break;
|
|
609
|
+
case "qa.fixes":
|
|
610
|
+
add({ ts, kind: "qa", type: e.type, agent: null, text: `QA round ${str(p.round)} fixes: ${str(p.merged ?? 0)} merged, ${str(p.failed ?? 0)} failed` });
|
|
611
|
+
break;
|
|
612
|
+
case "qa.capped":
|
|
613
|
+
add({ ts, kind: "qa", type: e.type, agent: null, text: `! QA stopped after round ${str(p.round)} · the last fixes weren't re-checked${Number(p.open) ? ` · ${str(p.open)} still open` : ""}` });
|
|
614
|
+
break;
|
|
615
|
+
case "qa.round": {
|
|
616
|
+
const a = pick(p, "agent", "by", "checker") || list(p.agents)[0] || e.agent;
|
|
617
|
+
if (isAgent(a))
|
|
618
|
+
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 });
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
case "qa.bug": {
|
|
623
|
+
const a = pick(p, "agent", "by", "found_by", "reporter") || e.agent;
|
|
624
|
+
st(a).bugsFound++;
|
|
625
|
+
if (isAgent(a))
|
|
626
|
+
close(a, ts, "warn");
|
|
627
|
+
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 });
|
|
629
|
+
talked(a, owner2);
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
case "qa.unchecked":
|
|
633
|
+
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
|
+
break;
|
|
635
|
+
case "qa.clean": {
|
|
636
|
+
const a = pick(p, "agent", "by") || e.agent;
|
|
637
|
+
if (isAgent(a))
|
|
638
|
+
close(a, ts, "ok");
|
|
639
|
+
add({ ts, kind: "qa", type: e.type, agent: a, text: `✓ QA${p.round !== undefined ? " round " + str(p.round) : ""} clean${pick(p, "summary", "note") ? " · " + oneLine(pick(p, "summary", "note"), 200) : ""}` });
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
case "qa.fixed":
|
|
643
|
+
case "qa.fix": {
|
|
644
|
+
const a = pick(p, "agent", "by", "fixed_by") || e.agent;
|
|
645
|
+
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)}` });
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
default: {
|
|
650
|
+
// Unknown (or newer) event: shown with every field, so nothing is lost.
|
|
651
|
+
const a = pick(p, "agent", "from", "by") || e.agent;
|
|
652
|
+
const fields = Object.entries(p)
|
|
653
|
+
.filter(([k, v]) => v !== null && v !== undefined && v !== "" && k !== "type")
|
|
654
|
+
.map(([k, v]) => `${k}=${oneLine(str(v), 80)}`)
|
|
655
|
+
.join(" ");
|
|
656
|
+
const kind = e.type.startsWith("council.") ? "design" : e.type.startsWith("qa.") ? "qa" : e.type.startsWith("takeover") || e.type.startsWith("split") ? "takeover" : "other";
|
|
657
|
+
if (/^qa\.(fix|fixed)/.test(e.type))
|
|
658
|
+
st(a).bugsFixed++;
|
|
659
|
+
add({ ts, kind, type: e.type, agent: a || null, to: pick(p, "to") || null, text: `[${e.type}]${e.task ? " " + q(title(e.task)) : ""}`, detail: fields || undefined });
|
|
660
|
+
see(a);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
for (const a of [...open.keys()])
|
|
665
|
+
close(a, run.end);
|
|
666
|
+
// Who proposed what: a council plan's tasks may say whose idea each one was.
|
|
667
|
+
for (const t of councilTasks) {
|
|
668
|
+
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(", ");
|
|
670
|
+
if (pt && who && !pt.by)
|
|
671
|
+
pt.by = who;
|
|
672
|
+
}
|
|
673
|
+
return { run, runs, agents, plan, design, items, spans, stats, talk, threads, merged, failed };
|
|
674
|
+
}
|
|
675
|
+
// ---- terminal -----------------------------------------------------------------
|
|
676
|
+
const clock = (ms) => {
|
|
677
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
678
|
+
return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
679
|
+
};
|
|
680
|
+
const kindMark = {
|
|
681
|
+
design: C.violet,
|
|
682
|
+
plan: C.violet,
|
|
683
|
+
task: (s) => s,
|
|
684
|
+
progress: C.faint,
|
|
685
|
+
announce: C.faint,
|
|
686
|
+
message: C.bold,
|
|
687
|
+
memory: C.violet,
|
|
688
|
+
handoff: C.violet,
|
|
689
|
+
review: C.violet,
|
|
690
|
+
merge: C.green,
|
|
691
|
+
fail: C.red,
|
|
692
|
+
conflict: C.yellow,
|
|
693
|
+
takeover: C.yellow,
|
|
694
|
+
qa: C.yellow,
|
|
695
|
+
team: C.faint,
|
|
696
|
+
other: C.faint,
|
|
697
|
+
};
|
|
698
|
+
export function renderText(s) {
|
|
699
|
+
const out = [];
|
|
700
|
+
const r = s.run;
|
|
701
|
+
const who = (a) => (a ? `${dot(a)} ${C.bold(a)}` : `${C.violet("◆")} ${C.faint("huddle")}`);
|
|
702
|
+
const line = (it) => {
|
|
703
|
+
const head = it.to && it.kind === "message" ? `${who(it.agent)} ${C.faint("→")} ${who(it.to)}${C.faint(":")}` : who(it.agent);
|
|
704
|
+
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")
|
|
707
|
+
for (const l of it.detail.split("\n").slice(0, 6))
|
|
708
|
+
out.push(C.faint(` ${l}`));
|
|
709
|
+
};
|
|
710
|
+
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(" ")}`);
|
|
714
|
+
if (s.design.length) {
|
|
715
|
+
out.push("", C.violet("DESIGN COUNCIL"));
|
|
716
|
+
s.design.forEach(line);
|
|
717
|
+
}
|
|
718
|
+
if (s.plan.tasks.length) {
|
|
719
|
+
out.push("", `${C.violet("PLAN")}${s.plan.by ? C.faint(" · by " + s.plan.by) : ""}${s.plan.note ? C.faint(" · " + s.plan.note) : ""} ${C.faint(`(${s.plan.tasks.length} task${s.plan.tasks.length > 1 ? "s" : ""})`)}`);
|
|
720
|
+
const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
|
|
721
|
+
s.plan.tasks.forEach((t, i) => {
|
|
722
|
+
const deps = t.deps.map((d) => "#" + (num.get(d) ?? d));
|
|
723
|
+
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}` : ""]
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
.map((x) => " · " + x)
|
|
727
|
+
.join("")));
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
out.push("", C.violet("TIMELINE"));
|
|
731
|
+
if (!s.items.length)
|
|
732
|
+
out.push(C.faint(" (nothing happened in this run)"));
|
|
733
|
+
s.items.forEach(line);
|
|
734
|
+
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("")));
|
|
739
|
+
for (const a of s.agents) {
|
|
740
|
+
const x = s.stats.get(a);
|
|
741
|
+
const vals = [
|
|
742
|
+
x.merged,
|
|
743
|
+
x.failed,
|
|
744
|
+
x.reviews + (x.changesAsked ? ` (${x.changesAsked}✎)` : ""),
|
|
745
|
+
`${x.sent}/${x.received}`,
|
|
746
|
+
x.proposals || x.critiques ? `${x.proposals}p ${x.critiques}c` : "0",
|
|
747
|
+
`${x.bugsFound}/${x.bugsFixed}`,
|
|
748
|
+
`${x.takeoversIn}/${x.takeoversOut}`,
|
|
749
|
+
x.toolCalls,
|
|
750
|
+
x.ms ? fmtDuration(x.ms) : "—",
|
|
751
|
+
x.tokens ? fmtTokens(x.tokens) : "—",
|
|
752
|
+
].map(String);
|
|
753
|
+
out.push(` ${pad(`${dot(a)} ${a}`, w)}${vals.map((v, i) => pad(v, Math.max(cols[i].length, 6) + 2)).join("")}`);
|
|
754
|
+
}
|
|
755
|
+
out.push("", `${C.violet("WHO TALKED TO WHOM")} ${C.faint("(row → column: messages, questions, answers, reviews, critiques, handoffs)")}`);
|
|
756
|
+
const cw = Math.max(8, ...s.agents.map((a) => a.length + 2));
|
|
757
|
+
out.push(" " + pad("", w) + s.agents.map((a) => pad(a, cw)).join(""));
|
|
758
|
+
for (const a of s.agents) {
|
|
759
|
+
const row = s.talk.get(a);
|
|
760
|
+
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
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return out.join("\n");
|
|
764
|
+
}
|
|
765
|
+
export function renderRunList(runs) {
|
|
766
|
+
if (!runs.length)
|
|
767
|
+
return C.faint(" no runs yet — type a goal to start.");
|
|
768
|
+
return runs
|
|
769
|
+
.map((r) => {
|
|
770
|
+
const merged = r.events.filter((e) => e.type === "run.merged").length;
|
|
771
|
+
const failed = r.events.filter((e) => e.type === "run.failed").length;
|
|
772
|
+
return ` ${C.bold(String(r.index).padStart(3))} ${C.faint(new Date(r.start).toLocaleString())} ${pad(fmtDuration(r.end - r.start), 7)} ${JSON.stringify(goalTitle(r.goal, 100))} ${C.faint(`· ${merged} merged${failed ? `, ${failed} failed` : ""}${r.id ? " · " + r.id : ""}`)}`;
|
|
773
|
+
})
|
|
774
|
+
.join("\n");
|
|
775
|
+
}
|
|
776
|
+
// ---- HTML (a static, self-contained report file) --------------------------------
|
|
777
|
+
const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
778
|
+
const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
|
|
779
|
+
export function renderHtml(s) {
|
|
780
|
+
const r = s.run;
|
|
781
|
+
const span = Math.max(1, r.end - r.start);
|
|
782
|
+
const pct = (ts) => (((ts - r.start) / span) * 100).toFixed(3);
|
|
783
|
+
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.
|
|
785
|
+
const ticks = Array.from({ length: 5 }, (_, i) => `<span style="left:${i * 25}%">${esc(clock((span * i) / 4))}</span>`).join("");
|
|
786
|
+
const lanes = s.agents
|
|
787
|
+
.map((a) => {
|
|
788
|
+
const bars = s.spans
|
|
789
|
+
.filter((x) => x.agent === a)
|
|
790
|
+
.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
|
+
.join("");
|
|
792
|
+
const marks = [...s.items, ...s.design]
|
|
793
|
+
.filter((it) => it.agent === a && ["message", "design", "qa", "takeover", "merge", "fail", "announce"].includes(it.kind))
|
|
794
|
+
.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
|
+
.join("");
|
|
796
|
+
return `<div class="lane" data-lane="${esc(a)}"><div class="name">${chip(a)}</div><div class="track">${bars}${marks}</div></div>`;
|
|
797
|
+
})
|
|
798
|
+
.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>`;
|
|
800
|
+
const num = new Map(s.plan.tasks.map((t, i) => [t.id, i + 1]));
|
|
801
|
+
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>`)
|
|
803
|
+
.join("");
|
|
804
|
+
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
|
|
806
|
+
.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
|
+
.join("")}</ol></details>`)
|
|
808
|
+
.join("");
|
|
809
|
+
const statRows = s.agents
|
|
810
|
+
.map((a) => {
|
|
811
|
+
const x = s.stats.get(a);
|
|
812
|
+
return `<tr><th>${chip(a)}</th><td>${x.merged}</td><td>${x.failed}</td><td>${x.reviews}${x.changesAsked ? ` (${x.changesAsked}✎)` : ""}</td><td>${x.sent}/${x.received}</td><td>${x.proposals}/${x.critiques}</td><td>${x.bugsFound}/${x.bugsFixed}</td><td>${x.takeoversIn}/${x.takeoversOut}</td><td>${x.toolCalls}</td><td>${x.ms ? esc(fmtDuration(x.ms)) : "—"}</td><td>${x.tokens ? esc(fmtTokens(x.tokens)) : "—"}</td></tr>`;
|
|
813
|
+
})
|
|
814
|
+
.join("");
|
|
815
|
+
const matrix = `<tr><th></th>${s.agents.map((a) => `<th>${esc(a)}</th>`).join("")}</tr>${s.agents
|
|
816
|
+
.map((a) => `<tr><th>${chip(a)}</th>${s.agents.map((b) => (a === b ? `<td class="self">·</td>` : `<td class="${s.talk.get(a)?.get(b) ? "hot" : ""}">${s.talk.get(a)?.get(b) ?? 0}</td>`)).join("")}</tr>`)
|
|
817
|
+
.join("")}`;
|
|
818
|
+
return `<!doctype html>
|
|
819
|
+
<html lang="en">
|
|
820
|
+
<head>
|
|
821
|
+
<meta charset="utf-8">
|
|
822
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
823
|
+
<title>Huddle story</title>
|
|
824
|
+
<style>
|
|
825
|
+
:root{--bg:#faf9f6;--fg:#1d1b18;--mute:#77716a;--line:#e4e0d8;--card:#fff;--ok:#2e9e5b;--bad:#d14b3c;--warn:#c58a12;--brand:#7B6CF6}
|
|
826
|
+
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15141a;--fg:#ece9e3;--mute:#9a948b;--line:#2c2a33;--card:#1d1c23}}
|
|
827
|
+
:root[data-theme="dark"]{--bg:#15141a;--fg:#ece9e3;--mute:#9a948b;--line:#2c2a33;--card:#1d1c23}
|
|
828
|
+
*{box-sizing:border-box}
|
|
829
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
830
|
+
main{max-width:1100px;margin:0 auto;padding:20px 16px 60px}
|
|
831
|
+
h1{font-size:22px;margin:0 0 4px}h2{font-size:15px;text-transform:uppercase;letter-spacing:.06em;color:var(--brand);margin:32px 0 10px}
|
|
832
|
+
.sub,small,.detail,time,.meta{color:var(--mute)}small{font-size:12px;margin-left:6px}
|
|
833
|
+
.who{display:inline-flex;align-items:center;gap:5px;font-weight:600;white-space:nowrap}.who i{width:9px;height:9px;border-radius:50%;display:inline-block}
|
|
834
|
+
.scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
835
|
+
.lanes{min-width:640px;padding:8px 12px 12px}
|
|
836
|
+
.axis{position:relative;height:18px;margin-left:120px;font-size:11px;color:var(--mute)}.axis span{position:absolute;transform:translateX(-50%)}.axis span:first-child{transform:none}.axis span:last-child{transform:translateX(-100%)}
|
|
837
|
+
.lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
|
|
838
|
+
.lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
|
|
839
|
+
.track{position:relative;flex:1;height:26px}
|
|
840
|
+
.bar{position:absolute;top:3px;height:20px;border-radius:5px;background:color-mix(in srgb,var(--c) 30%,transparent);border:1px solid var(--c);overflow:hidden;font-size:11px;line-height:18px;padding:0 4px;white-space:nowrap}
|
|
841
|
+
.bar span{opacity:.9}.bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
|
|
842
|
+
.bar.ok{box-shadow:inset -3px 0 0 var(--ok)}.bar.bad{box-shadow:inset -3px 0 0 var(--bad)}.bar.warn{box-shadow:inset -3px 0 0 var(--warn)}
|
|
843
|
+
.mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
|
|
844
|
+
.mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}.mark.announce{background:var(--mute)}
|
|
845
|
+
ol,ul{padding:0;margin:0;list-style:none}
|
|
846
|
+
.timeline li{display:flex;gap:10px;padding:7px 0;border-top:1px solid var(--line)}
|
|
847
|
+
.timeline time{flex:none;width:48px;font-variant-numeric:tabular-nums;font-size:13px}
|
|
848
|
+
.timeline .merge .text{color:var(--ok)}.timeline .fail .text{color:var(--bad)}.timeline .conflict .text,.timeline .qa .text,.timeline .takeover .text{color:var(--warn)}
|
|
849
|
+
.timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text{color:var(--mute)}
|
|
850
|
+
.arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
|
|
851
|
+
.plan li{padding:6px 0;border-top:1px solid var(--line)}.plan{counter-reset:n}.plan li::before{counter-increment:n;content:counter(n) ". ";color:var(--mute)}
|
|
852
|
+
details{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
|
|
853
|
+
.thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
|
854
|
+
.thread li{max-width:85%;padding:8px 10px;border-radius:12px;border:1px solid var(--line);border-left:4px solid var(--c);word-break:break-word}
|
|
855
|
+
.thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
|
|
856
|
+
table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
|
|
857
|
+
td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
|
|
858
|
+
</style>
|
|
859
|
+
</head>
|
|
860
|
+
<body>
|
|
861
|
+
<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>'}
|
|
872
|
+
<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>
|
|
874
|
+
<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>
|
|
878
|
+
</main>
|
|
879
|
+
</body>
|
|
880
|
+
</html>
|
|
881
|
+
`;
|
|
882
|
+
}
|
|
883
|
+
// ---- CLI ------------------------------------------------------------------------
|
|
884
|
+
/** `huddle story [--list] [--run <n|id>] [--html <file>]` against this repo's room. */
|
|
885
|
+
export async function runStory(args) {
|
|
886
|
+
const { dbPath } = await import("./app.js");
|
|
887
|
+
const flag = (name) => {
|
|
888
|
+
const i = args.indexOf(name);
|
|
889
|
+
if (i >= 0)
|
|
890
|
+
return args[i + 1];
|
|
891
|
+
return args.find((a) => a.startsWith(name + "="))?.slice(name.length + 1);
|
|
892
|
+
};
|
|
893
|
+
if (!fs.existsSync(dbPath())) {
|
|
894
|
+
console.log(C.faint(" no runs yet — this repo has no room (.huddle/huddle.db). Type a goal in `huddle` to start."));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const { RoomDB } = await import("../room/db.js");
|
|
898
|
+
const db = new RoomDB(dbPath());
|
|
899
|
+
let runs;
|
|
900
|
+
try {
|
|
901
|
+
runs = splitRuns(db.allEvents());
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
db.close();
|
|
905
|
+
}
|
|
906
|
+
if (args.includes("--list"))
|
|
907
|
+
return void console.log(renderRunList(runs));
|
|
908
|
+
const which = flag("--run") ?? args.find((a) => /^-?\d+$/.test(a));
|
|
909
|
+
const run = pickRun(runs, which);
|
|
910
|
+
if (!run) {
|
|
911
|
+
console.log(runs.length ? C.red(`✗ no run ${which}.`) + " See `huddle story --list`." : C.faint(" no runs yet — type a goal to start."));
|
|
912
|
+
if (runs.length)
|
|
913
|
+
process.exitCode = 1;
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const story = buildStory(run, runs.length);
|
|
917
|
+
const html = flag("--html");
|
|
918
|
+
if (args.includes("--html") && !html) {
|
|
919
|
+
console.log(C.red("✗ --html needs a file:") + " huddle story --html story.html");
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (html) {
|
|
924
|
+
const out = path.resolve(html);
|
|
925
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
926
|
+
fs.writeFileSync(out, renderHtml(story));
|
|
927
|
+
console.log(`${C.green("✓")} wrote ${out} ${C.faint(`· one file, no network · ${story.agents.length} lanes, ${story.items.length} events`)}`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
console.log(renderText(story));
|
|
931
|
+
}
|
|
932
|
+
//# sourceMappingURL=story.js.map
|