agent-horde 0.1.5 → 0.2.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.
@@ -20,25 +20,47 @@ import type {
20
20
  RunIndexVm,
21
21
  RunOverviewVm,
22
22
  SessionsVm,
23
+ SwarmGraphVm,
24
+ SwarmHorde,
25
+ SwarmLedgerVm,
26
+ SwarmVm,
23
27
  TranscriptEntryVm,
24
28
  TranscriptVm,
25
29
  } from "./dashboard-data.js";
26
- import { RUN_FILES } from "./dashboard-data.js";
30
+ import { RUN_FILES, SWARM_FILES } from "./dashboard-data.js";
31
+ import { buildInfo } from "./build-info.js";
27
32
  import { detailKey, formatClock, formatElapsed, formatTokens, oneLine } from "./format.js";
28
33
  import { renderRunGraphMermaid } from "./run-graph-mermaid.js";
29
34
  import { GRAPH_CSS, graphLegendHtml, renderRunGraphSvg } from "./run-graph-svg.js";
30
35
  import type { TranscriptEntry, TranscriptPart } from "./session-log.js";
31
36
 
37
+ /**
38
+ * What a page is about, fixed by its URL: the ledgers single runs share (`/`, `/findings`), one run
39
+ * (`/runs/<id>/...`), or a whole swarm (`/swarms/<id>/...`). A horde of a swarm is a run scope that
40
+ * also knows its swarm, so the nav can offer the sibling hordes. Every scoped link derives from this.
41
+ */
42
+ export type Scope =
43
+ | { kind: "project" }
44
+ | { kind: "run"; runId: string; swarm?: { id: string; hordes: SwarmHorde[] } | undefined }
45
+ | { kind: "swarm"; swarmId: string; hordes: SwarmHorde[] };
46
+
47
+ /** The page types every scope offers in the page row; `other` is a run-only page such as a transcript. */
48
+ export type PageKind =
49
+ | "overview" | "ideas" | "board" | "findings" | "claims" | "facts" | "resources" | "collaboration" | "graph" | "other";
50
+
32
51
  /** Per-request facts the renderers need beyond the view model. */
33
52
  export interface RenderContext {
34
53
  href?: string;
35
- runId?: string | undefined;
36
- activeId?: string | undefined;
54
+ scope?: Scope | undefined;
55
+ pageKind?: PageKind | undefined;
56
+ /**
57
+ * A swarm in memory, set on project pages only: its hordes write their own ledgers, so the
58
+ * project page points at the same page in the swarm. `finishing` once no horde is running.
59
+ */
60
+ liveSwarm?: { id: string; finishing: boolean } | undefined;
37
61
  refresh?: { on: boolean; toggleHref: string; seconds: number } | undefined;
38
62
  /** Scheme and host of the request, for exports that need absolute links. */
39
63
  origin?: string | undefined;
40
- /** The run the collaboration and graph links target: this page's run, else the newest. */
41
- navRun?: string | undefined;
42
64
  /** Rendering for the static export: no refresh, no export control, native disclosure. */
43
65
  export?: boolean | undefined;
44
66
  }
@@ -53,8 +75,8 @@ const CSS =
53
75
  "table{border-collapse:collapse;margin:4px 0}" +
54
76
  "th,td{border:1px solid #000;padding:2px 6px;vertical-align:top;text-align:left}" +
55
77
  "th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
56
- "nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
57
- "details>summary{cursor:pointer}a{color:#000}" +
78
+ "nav{margin:2px 0}nav a,nav b{margin-right:8px}.scope{margin:2px 0}.scope a,.scope b{margin-right:6px}.pointer{border:1px solid #000;padding:2px 6px;display:inline-block}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
79
+ "details>summary{cursor:pointer}a{color:#000}footer{margin-top:16px;color:#666}footer a{color:#666}" +
58
80
  ".export{position:absolute;top:8px;right:8px;line-height:0}" +
59
81
  ".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
60
82
  ".modal:target{display:flex;align-items:center;justify-content:center}" +
@@ -122,21 +144,92 @@ function memberHref(runId: string, member: string): string {
122
144
  return `${runHref(runId)}/members/${encodeURIComponent(member)}`;
123
145
  }
124
146
 
125
- function runLinks(run: string | undefined): string {
126
- if (!run) return "";
127
- return link(`${runHref(run)}/collaboration`, "collaboration") + link(`${runHref(run)}/graph`, "graph");
147
+ function swarmHref(swarmId: string): string {
148
+ return `/swarms/${encodeURIComponent(swarmId)}`;
128
149
  }
129
150
 
130
- function nav(ctx: RenderContext): string {
131
- const run = ctx.runId ?? ctx.activeId;
132
- const q = run ? query({ run }) : "";
151
+ /** The path every scoped link starts from: `` for the project, `/runs/<id>`, or `/swarms/<id>`. */
152
+ function scopeBase(scope: Scope | undefined): string {
153
+ if (scope?.kind === "run") return runHref(scope.runId);
154
+ if (scope?.kind === "swarm") return swarmHref(scope.swarmId);
155
+ return "";
156
+ }
157
+
158
+ /** The run a page's member names link into; none in the project or swarm scope. */
159
+ function scopeRunId(ctx: RenderContext): string | undefined {
160
+ return ctx.scope?.kind === "run" ? ctx.scope.runId : undefined;
161
+ }
162
+
163
+ const LEDGER_PAGES = ["ideas", "board", "findings", "claims", "facts", "resources"] as const;
164
+
165
+ /** The project scope has its ledgers only; a run or swarm also has an overview, collaboration and graph. */
166
+ function pagesOf(scope: Scope | undefined): PageKind[] {
167
+ return scope === undefined || scope.kind === "project"
168
+ ? [...LEDGER_PAGES]
169
+ : ["overview", ...LEDGER_PAGES, "collaboration", "graph"];
170
+ }
171
+
172
+ /** The same page type under another base; pages a scope does not share fall back to its overview. */
173
+ function pageHref(base: string, kind: PageKind | undefined): string {
174
+ if (kind === undefined || kind === "overview" || kind === "other") return base || "/";
175
+ return `${base}/${kind}`;
176
+ }
177
+
178
+ /** The current item is shown, not linked. */
179
+ function navItem(href: string, text: string, current: boolean): string {
180
+ return current ? `<b>${e(text)}</b>` : link(href, text);
181
+ }
182
+
183
+ /** `all | h1 | h2 ...` for a swarm or one of its hordes; each tab keeps the page type. */
184
+ function hordeTabs(swarmId: string, hordes: SwarmHorde[], current: string | undefined, kind: PageKind | undefined): string {
185
+ const tabs = [
186
+ navItem(pageHref(swarmHref(swarmId), kind), "all", current === undefined),
187
+ ...hordes.map((horde) => navItem(pageHref(runHref(horde.runId), kind), horde.label, horde.runId === current)),
188
+ ];
189
+ return ` hordes: ${tabs.join(" ")}`;
190
+ }
191
+
192
+ /** On a project page while a swarm is live: where the swarm's activity is, on the same page type. */
193
+ function swarmPointer(ctx: RenderContext): string {
194
+ const swarm = ctx.liveSwarm;
195
+ if (!swarm) return "";
196
+ const target = ctx.pageKind === undefined || ctx.pageKind === "overview" || ctx.pageKind === "other"
197
+ ? "the swarm"
198
+ : `the swarm's ${ctx.pageKind}`;
133
199
  return (
134
- `<nav>${link("/", "runs")}${link("/ideas", "ideas")}${link(`/board${q}`, "board")}` +
135
- `${link(`/findings${q}`, "findings")}${link("/claims", "claims")}${link(`/facts${q}`, "facts")}` +
136
- `${link("/resources", "resources")}${runLinks(ctx.navRun ?? run)}</nav>`
200
+ `<p class="pointer">swarm ${e(swarm.id)} is ${swarm.finishing ? "finishing" : "running"} in its own ledgers, ` +
201
+ `not these: ${link(pageHref(swarmHref(swarm.id), ctx.pageKind), target)}</p>`
137
202
  );
138
203
  }
139
204
 
205
+ /** The scope line: what this page is about, and inside a swarm the tabs to move between hordes. */
206
+ function scopeLine(ctx: RenderContext): string {
207
+ const scope = ctx.scope;
208
+ if (scope === undefined || scope.kind === "project") {
209
+ return `<p class="scope">scope: <b>project</b></p>${swarmPointer(ctx)}`;
210
+ }
211
+ if (scope.kind === "swarm") {
212
+ return `<p class="scope">scope: <b>swarm ${e(scope.swarmId)}</b>${hordeTabs(scope.swarmId, scope.hordes, undefined, ctx.pageKind)}</p>`;
213
+ }
214
+ if (!scope.swarm) return `<p class="scope">scope: <b>run ${e(scope.runId)}</b></p>`;
215
+ const horde = scope.swarm.hordes.find((h) => h.runId === scope.runId)?.label ?? "horde";
216
+ return (
217
+ `<p class="scope">scope: ${link(swarmHref(scope.swarm.id), `swarm ${scope.swarm.id}`)} &rsaquo; <b>${e(horde)}</b>` +
218
+ `${hordeTabs(scope.swarm.id, scope.swarm.hordes, scope.runId, ctx.pageKind)}</p>`
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Three rows: `runs` to the index, the scope line, and the page row. Every link in the page row is
224
+ * built from the page's own scope, so nothing silently points at some other run.
225
+ */
226
+ function nav(ctx: RenderContext): string {
227
+ const base = scopeBase(ctx.scope);
228
+ const pages = pagesOf(ctx.scope).map((kind) => navItem(pageHref(base, kind), kind, kind === ctx.pageKind));
229
+ const onIndex = (ctx.href ?? "").split("?")[0] === "/";
230
+ return `<nav>${navItem("/", "runs", onIndex)}</nav>${scopeLine(ctx)}<nav>${pages.join("")}</nav>`;
231
+ }
232
+
140
233
  /** An id cell that deep links, including the run graph, can target. */
141
234
  function anchor(id: string): string {
142
235
  return `<span id="${e(id)}">${e(id)}</span>`;
@@ -154,21 +247,37 @@ function messageThread(m: PeerMessage): string {
154
247
  return lines.join("\n");
155
248
  }
156
249
 
157
- export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): string {
250
+ /** Acceptance, requests, artifacts, reviews, resources and metrics; the event audit only on a run's own page. */
251
+ function collaborationBody(vm: CollaborationVm, withAudit: boolean): string {
158
252
  const { report } = vm;
159
253
  const { state } = report;
160
254
  const messages = table(["request", "owner", "state / age", "target", "question / response"], state.messages.map((m) => [
161
255
  anchor(m.id), e(`${m.sender} -> ${m.to}`), e(`${m.state}${m.blocking ? " BLOCKING" : ""} / ${formatElapsed(Date.now() - Date.parse(m.createdAt))}`),
162
256
  e(`${m.target}@${m.revision}`), pre(messageThread(m)),
163
257
  ]));
164
- const reviews = table(["review", "target revision", "verdict", "scope / evidence"], state.reviews.map((r) => [`${anchor(r.id)} @${e(r.actor)}`, e(`${r.target}@${r.revision}`), e(`${r.verdict} [${r.support}]`), pre(`Checked: ${r.checked}\nUnchecked: ${r.unchecked || "none declared"}\nEvidence: ${r.evidence.join(", ")}\n${r.text}`)]));
258
+ const reviews = table(["review", "target revision", "verdict", "scope / evidence"], state.reviews.map((r) => [`${anchor(r.id)} @${e(r.actor)}`, e(`${r.target}@${r.revision}`), e(`${r.verdict} [${r.support}]`), pre(`Checked: ${r.checked}\nUnchecked: ${r.unchecked || "none declared"}\nLimits: ${r.limits || "none disclosed"}\nEvidence: ${r.evidence.join(", ")}${r.reattests ? `\nReattests: ${r.reattests}` : ""}\n${r.text}`)]));
165
259
  const artifacts = table(["artifact", "hash / dependencies", "acceptance"], state.artifacts.map((a) => [`${anchor(a.id)} ${e(`${a.path} @${a.owner} (${a.kind ?? "runnable"})`)}`, pre(`${a.hash}\n${JSON.stringify(a.dependencies)}`), e(report.acceptedArtifacts.includes(a.id) ? "accepted" : "excluded / needs review")]));
166
- const events = vm.events.map((event) => `<div id="${e(event.eventId)}">${details(`${event.eventId} ${event.at} ${event.actor}/${event.attempt} ${event.action} ${event.outcome}`, pre(JSON.stringify(event, null, 2)), `event:${report.runId}:${event.eventId}`)}</div>`).join("");
167
- return page(`Collaboration ${report.runId}`, [{ text: "runs", href: "/" }, { text: report.runId, href: runHref(report.runId) }, { text: "collaboration" }],
260
+ const summary =
168
261
  (vm.historical ? "<p>Historical run: no delivery/revision history was recorded.</p>" : "") +
169
262
  `<h2>Acceptance issues</h2>${pre(report.issues.join("\n") || "none recorded")}<h2>Requests</h2>${messages}<h2>Artifacts</h2>${artifacts}<h2>Reviews</h2>${reviews}` +
170
- `<h2>Resource readiness and leases</h2>${pre(JSON.stringify(state.resources, null, 2))}<h2>Metrics</h2>${pre(JSON.stringify(report.metrics, null, 2))}<h2>Event audit</h2>${events}` +
171
- (vm.next !== undefined ? link(`${runHref(report.runId)}/collaboration?from=${vm.next}`, "next 100 events") : ""), ctx);
263
+ `<h2>Resource readiness and leases</h2>${pre(JSON.stringify(state.resources, null, 2))}<h2>Metrics</h2>${pre(JSON.stringify(report.metrics, null, 2))}`;
264
+ if (!withAudit) {
265
+ return `${summary}<p>${link(`${runHref(report.runId)}/collaboration`, "event audit")} on the horde's own page</p>`;
266
+ }
267
+ const events = vm.events.map((event) => `<div id="${e(event.eventId)}">${details(`${event.eventId} ${event.at} ${event.actor}/${event.attempt} ${event.action} ${event.outcome}`, pre(JSON.stringify(event, null, 2)), `event:${report.runId}:${event.eventId}`)}</div>`).join("");
268
+ return `${summary}<h2>Event audit</h2>${events}` +
269
+ (vm.next !== undefined ? link(`${runHref(report.runId)}/collaboration?from=${vm.next}`, "next 100 events") : "");
270
+ }
271
+
272
+ export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): string {
273
+ const runId = vm.report.runId;
274
+ const crumbs = [{ text: "runs", href: "/" }, { text: runId, href: runHref(runId) }, { text: "collaboration" }];
275
+ return page(`Collaboration ${runId}`, crumbs, collaborationBody(vm, true), ctx);
276
+ }
277
+
278
+ /** Each horde's acceptance, requests and reviews, under per-horde headings. */
279
+ export function renderSwarmCollaboration(vm: SwarmLedgerVm<CollaborationVm>, ctx: RenderContext): string {
280
+ return swarmLedgerPage("collaboration", vm, (section) => collaborationBody(section, false), ctx);
172
281
  }
173
282
 
174
283
  function crumbLine(crumbs: Crumb[]): string {
@@ -230,13 +339,20 @@ function nativeDetails(body: string): string {
230
339
  return body.replace(/<details data-expand="[a-f0-9]+">/g, "<details>");
231
340
  }
232
341
 
342
+ /** Which agent-horde produced the page: the package version and, from a checkout, the commit. */
343
+ function footer(): string {
344
+ const { name, version, commit, repository } = buildInfo();
345
+ const at = commit ? ` at ${repository ? link(`${repository}/commit/${commit.replace(/-dirty$/, "")}`, commit) : e(commit)}` : "";
346
+ return `<footer>${e(name)} ${e(version)}${at}</footer>`;
347
+ }
348
+
233
349
  export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext, head = ""): string {
234
350
  body = ctx.export ? nativeDetails(body) : persistentDetails(body, ctx.href);
235
351
  const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : head;
236
352
  return (
237
353
  `<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title>${meta}` +
238
354
  `<style>${CSS}</style></head><body>${exportControl(ctx)}${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
239
- `<h1>${e(title)}</h1>${body}</body></html>`
355
+ `<h1>${e(title)}</h1>${body}${footer()}</body></html>`
240
356
  );
241
357
  }
242
358
 
@@ -261,10 +377,31 @@ function cost(value: number): string {
261
377
  return `$${value.toFixed(4)}`;
262
378
  }
263
379
 
380
+ /** The state column: a live run stands out; pending, finalizing, finished and not started read as written. */
381
+ function stateCell(state: string): string {
382
+ return state === "active" ? "<b>ACTIVE</b>" : e(state);
383
+ }
384
+
385
+ function swarmIndexTable(vm: RunIndexVm): string {
386
+ if (vm.swarms.length === 0) return "";
387
+ const rows = vm.swarms.map((swarm) => [
388
+ link(swarmHref(swarm.id), swarm.id),
389
+ stateCell(swarm.state),
390
+ e(swarm.started ?? ""),
391
+ `${swarm.hordes} x ${num(swarm.count) || "?"}`,
392
+ e(swarm.outcomes.map((outcome, index) => `h${index + 1} ${outcome || "?"}`).join(", ")),
393
+ e(oneLine(swarm.prompt, 100)),
394
+ ...SWARM_FILES.map((name) => yn(swarm.files[name])),
395
+ ]);
396
+ const headers = ["swarm", "state", "started", "hordes", "outcomes", "prompt", ...SWARM_FILES.map((n) => n.toLowerCase())];
397
+ return `<h2>swarms</h2>${table(headers, rows)}<h2>runs</h2>`;
398
+ }
399
+
264
400
  export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
265
401
  const rows = vm.runs.map((run) => [
266
402
  link(runHref(run.id), run.id),
267
- run.active ? "<b>ACTIVE</b>" : run.finalizing ? "finalizing" : "finished",
403
+ run.swarm ? link(swarmHref(run.swarm), run.swarm) : "",
404
+ stateCell(run.state),
268
405
  e(run.started ?? ""),
269
406
  num(run.count),
270
407
  e(run.model ?? ""),
@@ -272,8 +409,8 @@ export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
272
409
  e(oneLine(run.prompt, 100)),
273
410
  ...RUN_FILES.map((name) => yn(run.files[name])),
274
411
  ]);
275
- const headers = ["run", "state", "started", "members", "model", "outcome", "prompt", ...RUN_FILES.map((n) => n.toLowerCase())];
276
- return page("horde runs", [{ text: "runs" }], table(headers, rows, "no runs in .horde/runs"), ctx);
412
+ const headers = ["run", "swarm", "state", "started", "members", "model", "outcome", "prompt", ...RUN_FILES.map((n) => n.toLowerCase())];
413
+ return page("horde runs", [{ text: "runs" }], swarmIndexTable(vm) + table(headers, rows, "no runs in .horde/runs"), ctx);
277
414
  }
278
415
 
279
416
  function memberRow(runId: string, m: MemberRowVm): string[] {
@@ -523,7 +660,8 @@ export function renderSessions(vm: SessionsVm, ctx: RenderContext): string {
523
660
  }
524
661
 
525
662
  export function renderFile(vm: FileVm, ctx: RenderContext): string {
526
- const crumbs = [{ text: "runs", href: "/" }, { text: vm.runId, href: runHref(vm.runId) }, { text: `${vm.name}.md` }];
663
+ const parent = vm.swarm ? swarmHref(vm.runId) : runHref(vm.runId);
664
+ const crumbs = [{ text: "runs", href: "/" }, { text: vm.runId, href: parent }, { text: `${vm.name}.md` }];
527
665
  const note = vm.truncated ? `<p>showing the first ${vm.content.length} of ${vm.bytes} characters</p>` : "";
528
666
  return page(`${vm.name}.md`, crumbs, note + pre(vm.content), ctx);
529
667
  }
@@ -540,51 +678,50 @@ function ideaRows(ideas: Idea[], ctx: RenderContext): string[][] {
540
678
  return ideas.map((idea) => [
541
679
  `<span id="idea-${idea.id}">#${idea.id}</span>`,
542
680
  e(idea.text),
543
- idea.owner ? authorLink(idea.owner, ctx.activeId) : "",
681
+ idea.owner ? authorLink(idea.owner, scopeRunId(ctx)) : "",
544
682
  e(idea.note ?? ""),
545
683
  ]);
546
684
  }
547
685
 
548
- export function renderIdeas(vm: IdeasVm, ctx: RenderContext): string {
549
- const filters = [
550
- vm.owner ? link("/ideas", "all") : "all",
551
- ...vm.owners.map((o) => (o === vm.owner ? e(o) : link(`/ideas${query({ owner: o })}`, o))),
552
- ];
686
+ function ideasBody(vm: IdeasVm, ctx: RenderContext): string {
553
687
  const sections: Array<[string, Idea[]]> = [
554
688
  ["in progress", vm.inProgress], ["open", vm.open], ["done", vm.done], ["ruled out", vm.ruledOut],
555
689
  ];
556
- const body =
557
- `<p>owner: ${filters.join(" ")}</p>` +
558
- sections
559
- .map(([title, ideas]) => `<h2>${e(title)} (${ideas.length})</h2>${table(["id", "idea", "owner", "note"], ideaRows(ideas, ctx))}`)
560
- .join("");
561
- return page("ideas", [{ text: "ideas" }], body, ctx);
690
+ return sections
691
+ .map(([title, ideas]) => `<h2>${e(title)} (${ideas.length})</h2>${table(["id", "idea", "owner", "note"], ideaRows(ideas, ctx))}`)
692
+ .join("");
562
693
  }
563
694
 
564
- function runPicker(base: string, current: string, runs: string[], extra: Record<string, string | undefined>): string {
565
- const items = ["all", ...runs].map((run) =>
566
- run === current ? e(run) : link(`${base}${query({ ...extra, run })}`, run),
567
- );
568
- return `<p>run: ${items.join(" ")}</p>`;
695
+ export function renderIdeas(vm: IdeasVm, ctx: RenderContext): string {
696
+ const base = `${scopeBase(ctx.scope)}/ideas`;
697
+ const filters = [
698
+ vm.owner ? link(base, "all") : "all",
699
+ ...vm.owners.map((o) => (o === vm.owner ? e(o) : link(`${base}${query({ owner: o })}`, o))),
700
+ ];
701
+ return page("ideas", [{ text: "ideas" }], `<p>owner: ${filters.join(" ")}</p>${ideasBody(vm, ctx)}`, ctx);
569
702
  }
570
703
 
571
- export function renderBoard(vm: BoardVm, ctx: RenderContext): string {
572
- const other = vm.order === "newest" ? "oldest" : "newest";
704
+ function boardBody(vm: BoardVm, ctx: RenderContext): string {
573
705
  const rows = vm.entries.map((entry: BoardEntry) => [
574
706
  entry.runId ? link(runHref(entry.runId), entry.runId) : "",
575
707
  e(entry.time),
576
- authorLink(entry.author, entry.runId || ctx.activeId),
708
+ authorLink(entry.author, entry.runId || scopeRunId(ctx)),
577
709
  pre(entry.text),
578
710
  ]);
711
+ return table(["run", "time", "author", "message"], rows, "no posts");
712
+ }
713
+
714
+ export function renderBoard(vm: BoardVm, ctx: RenderContext): string {
715
+ const other = vm.order === "newest" ? "oldest" : "newest";
579
716
  const body =
580
- runPicker("/board", vm.run, vm.runs, { order: vm.order }) +
581
- `<p>order: ${vm.order} ${link(`/board${query({ run: vm.run, order: other })}`, `[${other} first]`)}</p>` +
582
- table(["run", "time", "author", "message"], rows, "no posts");
717
+ `<p>order: ${vm.order} ${link(`${scopeBase(ctx.scope)}/board${query({ order: other })}`, `[${other} first]`)}</p>` +
718
+ boardBody(vm, ctx);
583
719
  return page("board", [{ text: "board" }], body, ctx);
584
720
  }
585
721
 
586
- function findingsHref(vm: FindingsVm, open: number[]): string {
587
- return `/findings${query({ run: vm.run, view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
722
+ /** The findings page of the scope this finding is shown in, with a body opened or closed. */
723
+ function findingsHref(vm: FindingsVm, ctx: RenderContext, open: number[]): string {
724
+ return `${scopeBase(ctx.scope)}/findings${query({ view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
588
725
  }
589
726
 
590
727
  /** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
@@ -592,13 +729,14 @@ function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): strin
592
729
  if (!f.body) return "";
593
730
  if (ctx.export) return pre(f.body);
594
731
  if (vm.open.includes(f.id)) {
595
- return pre(f.body) + link(findingsHref(vm, vm.open.filter((id) => id !== f.id)), "[hide body]");
732
+ return pre(f.body) + link(findingsHref(vm, ctx, vm.open.filter((id) => id !== f.id)), "[hide body]");
596
733
  }
597
- return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
734
+ return ` ${link(findingsHref(vm, ctx, [...vm.open, f.id]), "[show body]")}`;
598
735
  }
599
736
 
600
737
  function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
601
- const idea = f.ideaId === undefined ? "" : link(`/ideas#idea-${f.ideaId}`, `#${f.ideaId}`);
738
+ const ideas = f.runId ? `${runHref(f.runId)}/ideas` : "/ideas";
739
+ const idea = f.ideaId === undefined ? "" : link(`${ideas}#idea-${f.ideaId}`, `#${f.ideaId}`);
602
740
  const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
603
741
  const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
604
742
  return [
@@ -623,83 +761,138 @@ const FINDING_HEADERS = [
623
761
  "superseded by", "review status", "title",
624
762
  ];
625
763
 
764
+ function findingsBody(vm: FindingsVm, ctx: RenderContext): string {
765
+ if (!vm.clusters) return table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
766
+ if (vm.clusters.length === 0) return "<p>no findings</p>";
767
+ return vm.clusters
768
+ .map((cluster, i) => {
769
+ const members = cluster.members.join(", ") || "none";
770
+ const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
771
+ return (
772
+ `<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
773
+ table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
774
+ );
775
+ })
776
+ .join("");
777
+ }
778
+
626
779
  export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
627
780
  const other = vm.view === "list" ? "clusters" : "list";
628
781
  const open = vm.open.length > 0 ? vm.open.join(",") : undefined;
629
- let body =
630
- runPicker("/findings", vm.run, vm.runs, { view: vm.view, open }) +
631
- `<p>view: ${vm.view} ${link(`/findings${query({ run: vm.run, view: other, open })}`, `[${other}]`)}</p>`;
632
- if (vm.clusters) {
633
- body += vm.clusters
634
- .map((cluster, i) => {
635
- const members = cluster.members.join(", ") || "none";
636
- const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
637
- return (
638
- `<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
639
- table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
640
- );
641
- })
642
- .join("");
643
- if (vm.clusters.length === 0) body += "<p>no findings</p>";
644
- } else {
645
- body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
646
- }
782
+ const body =
783
+ `<p>view: ${vm.view} ${link(`${scopeBase(ctx.scope)}/findings${query({ view: other, open })}`, `[${other}]`)}</p>` +
784
+ findingsBody(vm, ctx);
647
785
  return page("findings", [{ text: "findings" }], body, ctx);
648
786
  }
649
787
 
650
- export function renderClaims(vm: ClaimsVm, ctx: RenderContext): string {
788
+ function claimsBody(vm: ClaimsVm, ctx: RenderContext): string {
651
789
  const body = vm.groups
652
790
  .map(
653
791
  (group) =>
654
792
  `<h2>${e(group.state)} (${group.claims.length})</h2>` +
655
793
  table(
656
794
  ["id", "statement", "owner", "notes"],
657
- group.claims.map((c: ClaimEntry) => [anchor(`C${c.id}`), e(c.statement), authorLink(c.owner, ctx.activeId), e(c.notes.join(" | "))]),
795
+ group.claims.map((c: ClaimEntry) => [anchor(`C${c.id}`), e(c.statement), authorLink(c.owner, scopeRunId(ctx)), e(c.notes.join(" | "))]),
658
796
  ),
659
797
  )
660
798
  .join("");
661
- return page("claims", [{ text: "claims" }], body || "<p>no claims</p>", ctx);
799
+ return body || "<p>no claims</p>";
800
+ }
801
+
802
+ export function renderClaims(vm: ClaimsVm, ctx: RenderContext): string {
803
+ return page("claims", [{ text: "claims" }], claimsBody(vm, ctx), ctx);
662
804
  }
663
805
 
664
806
  function factRows(facts: FactEntry[], ctx: RenderContext): string[][] {
665
807
  return facts.map((f) => [
666
808
  anchor(`T${f.id}`),
667
809
  e(f.text),
668
- authorLink(f.author, ctx.activeId),
810
+ authorLink(f.author, scopeRunId(ctx)),
669
811
  f.deltaOf === undefined ? "" : `T${f.deltaOf}`,
670
812
  ]);
671
813
  }
672
814
 
673
- export function renderFacts(vm: FactsVm, ctx: RenderContext): string {
815
+ function factsBody(vm: FactsVm, ctx: RenderContext): string {
674
816
  const headers = ["id", "fact", "author", "delta of"];
675
- const body =
676
- vm.attribution === "split"
677
- ? `<h2>this run (${vm.thisRun.length})</h2>${table(headers, factRows(vm.thisRun, ctx))}` +
678
- `<h2>carried from earlier runs (${vm.carried.length})</h2>${table(headers, factRows(vm.carried, ctx))}`
679
- : `<p>${vm.run ? "per-run attribution is only available for the active run; " : ""}all facts:</p>` +
680
- table(headers, factRows(vm.all, ctx), "no facts");
681
- return page("facts", [{ text: "facts" }], body, ctx);
817
+ return vm.attribution === "split"
818
+ ? `<h2>this run (${vm.thisRun.length})</h2>${table(headers, factRows(vm.thisRun, ctx))}` +
819
+ `<h2>carried from earlier runs (${vm.carried.length})</h2>${table(headers, factRows(vm.carried, ctx))}`
820
+ : `<p>${vm.run ? "per-run attribution is only available for the active run; " : ""}all facts:</p>` +
821
+ table(headers, factRows(vm.all, ctx), "no facts");
682
822
  }
683
823
 
684
- export function renderResources(vm: ResourcesVm, ctx: RenderContext): string {
824
+ export function renderFacts(vm: FactsVm, ctx: RenderContext): string {
825
+ return page("facts", [{ text: "facts" }], factsBody(vm, ctx), ctx);
826
+ }
827
+
828
+ function resourcesBody(vm: ResourcesVm, ctx: RenderContext): string {
685
829
  const rows = vm.resources.map((resource) => [
686
830
  anchor(`R${resource.id}`),
687
831
  e(resource.name),
688
- authorLink(resource.owner, ctx.activeId),
832
+ authorLink(resource.owner, scopeRunId(ctx)),
689
833
  e(resource.note ?? ""),
690
- resource.reusers.map((name) => authorLink(name, ctx.activeId)).join(", "),
691
- resource.citedBy.map((name) => authorLink(name, ctx.activeId)).join(", "),
834
+ resource.reusers.map((name) => authorLink(name, scopeRunId(ctx))).join(", "),
835
+ resource.citedBy.map((name) => authorLink(name, scopeRunId(ctx))).join(", "),
692
836
  e(resource.cleanup ?? ""),
693
837
  ]);
694
838
  const scope = vm.run
695
839
  ? `<p>cited by: non-owners whose findings, evidence, reviews or leases name the resource in run ${e(vm.run)}</p>`
696
840
  : "<p>cited by: needs an active run; only explicit reclaims are shown</p>";
697
- return page(
698
- "resources",
699
- [{ text: "resources" }],
700
- scope + table(["id", "name", "owner", "note", "reused by", "cited by", "cleanup"], rows, "no shared resources claimed"),
701
- ctx,
702
- );
841
+ return scope + table(["id", "name", "owner", "note", "reused by", "cited by", "cleanup"], rows, "no shared resources claimed");
842
+ }
843
+
844
+ export function renderResources(vm: ResourcesVm, ctx: RenderContext): string {
845
+ return page("resources", [{ text: "resources" }], resourcesBody(vm, ctx), ctx);
846
+ }
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // Swarm ledgers: every horde's ledger on one page, under per-horde headings
850
+ // ---------------------------------------------------------------------------
851
+
852
+ /** Render a ledger aggregated across a swarm: one `<h2>hN</h2>` section per horde, ids stay unambiguous. */
853
+ function swarmLedgerPage<T>(
854
+ kind: string,
855
+ vm: SwarmLedgerVm<T>,
856
+ body: (sectionVm: T, ctx: RenderContext) => string,
857
+ ctx: RenderContext,
858
+ ): string {
859
+ const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: `swarm ${vm.swarmId}`, href: swarmHref(vm.swarmId) }, { text: kind }];
860
+ // Links inside a section (member names, finding bodies, idea anchors) belong to that horde.
861
+ const sectionCtx = (runId: string): RenderContext => ({ ...ctx, scope: { kind: "run", runId } });
862
+ const inner =
863
+ vm.sections.length === 0
864
+ ? "<p>this swarm has no hordes</p>"
865
+ : `<p>${kind} across ${vm.sections.length} hordes; ids repeat per horde, so each section is one horde's ledger</p>` +
866
+ vm.sections
867
+ .map((section) =>
868
+ `<h2>${e(section.label)} &mdash; ${link(pageHref(runHref(section.runId), ctx.pageKind), section.runId)}</h2>` +
869
+ body(section.vm, sectionCtx(section.runId)))
870
+ .join("");
871
+ return page(kind, crumbs, inner, ctx);
872
+ }
873
+
874
+ export function renderSwarmIdeas(vm: SwarmLedgerVm<IdeasVm>, ctx: RenderContext): string {
875
+ return swarmLedgerPage("ideas", vm, ideasBody, ctx);
876
+ }
877
+
878
+ export function renderSwarmBoard(vm: SwarmLedgerVm<BoardVm>, ctx: RenderContext): string {
879
+ return swarmLedgerPage("board", vm, boardBody, ctx);
880
+ }
881
+
882
+ export function renderSwarmFindings(vm: SwarmLedgerVm<FindingsVm>, ctx: RenderContext): string {
883
+ return swarmLedgerPage("findings", vm, findingsBody, ctx);
884
+ }
885
+
886
+ export function renderSwarmClaims(vm: SwarmLedgerVm<ClaimsVm>, ctx: RenderContext): string {
887
+ return swarmLedgerPage("claims", vm, claimsBody, ctx);
888
+ }
889
+
890
+ export function renderSwarmFacts(vm: SwarmLedgerVm<FactsVm>, ctx: RenderContext): string {
891
+ return swarmLedgerPage("facts", vm, factsBody, ctx);
892
+ }
893
+
894
+ export function renderSwarmResources(vm: SwarmLedgerVm<ResourcesVm>, ctx: RenderContext): string {
895
+ return swarmLedgerPage("resources", vm, resourcesBody, ctx);
703
896
  }
704
897
 
705
898
  // ---------------------------------------------------------------------------
@@ -746,3 +939,105 @@ export function renderGraph(vm: RunGraphVm, ctx: RenderContext): string {
746
939
  export function renderGraphMermaid(vm: RunGraphVm, ctx: RenderContext): string {
747
940
  return renderRunGraphMermaid(vm.graph, ctx.origin ?? "");
748
941
  }
942
+
943
+ // ---------------------------------------------------------------------------
944
+ // Swarms
945
+ // ---------------------------------------------------------------------------
946
+
947
+ const SWARM_HORDE_HEADERS = [
948
+ "horde", "run", "state", "outcome", "members", "running", "done", "failed", "stopped", "turns", "in", "out",
949
+ "cost", "findings", "accepted artifacts", "elapsed", "workstream",
950
+ ];
951
+
952
+ function swarmHordeRow(row: SwarmVm["rows"][number]): string[] {
953
+ return [
954
+ e(row.label),
955
+ link(runHref(row.runId), row.runId),
956
+ stateCell(row.state),
957
+ e(row.outcome ?? ""),
958
+ String(row.count),
959
+ String(row.counts.running + row.counts.starting),
960
+ String(row.counts.done),
961
+ String(row.counts.failed),
962
+ String(row.counts.stopped),
963
+ String(row.totals.turns),
964
+ formatTokens(row.totals.input),
965
+ formatTokens(row.totals.output),
966
+ cost(row.totals.cost),
967
+ String(row.findings),
968
+ String(row.acceptedArtifacts),
969
+ row.elapsedMs === undefined ? "" : formatElapsed(row.elapsedMs),
970
+ e(oneLine(row.slice ?? "", 120)),
971
+ ];
972
+ }
973
+
974
+ function swarmHeader(vm: SwarmVm): string {
975
+ const rows: Array<[string, string]> = [
976
+ ["state", stateCell(vm.state) + (vm.state === "pending" ? " (the planner is partitioning the task, or the launch did not reach the hordes)" : "")],
977
+ ["prompt", pre(vm.prompt)],
978
+ ["hordes", `${vm.hordes} x ${vm.count ?? "?"} members`],
979
+ ["started", e(vm.started ?? "")],
980
+ ["elapsed", vm.elapsedMs === undefined ? "" : formatElapsed(vm.elapsedMs)],
981
+ ["until stop", vm.untilStop ? "yes" : "no"],
982
+ ["merge", e(vm.merge)],
983
+ ];
984
+ if (vm.notStarted) rows.push(["not started", e(vm.notStarted)]);
985
+ if (vm.partition.length > 0) {
986
+ rows.push(["partition", pre(vm.partition.map((slice, index) => `h${index + 1}: ${slice}`).join("\n"))]);
987
+ }
988
+ return grid(rows);
989
+ }
990
+
991
+ function swarmFiles(vm: SwarmVm): string {
992
+ const cells = SWARM_FILES.map((name) =>
993
+ vm.files[name] ? link(`${swarmHref(vm.id)}/files/${name}`, `${name}.md`) : `${name}.md: not yet`,
994
+ );
995
+ return `<p>${cells.join(" | ")} | ${link(`${swarmHref(vm.id)}/graph`, "swarm graph")}</p>`;
996
+ }
997
+
998
+ export function renderSwarm(vm: SwarmVm, ctx: RenderContext): string {
999
+ const totals = [
1000
+ "<b>total</b>", "", "", "",
1001
+ String(vm.rows.reduce((n, r) => n + r.count, 0)),
1002
+ String(vm.rows.reduce((n, r) => n + r.counts.running + r.counts.starting, 0)),
1003
+ String(vm.rows.reduce((n, r) => n + r.counts.done, 0)),
1004
+ String(vm.rows.reduce((n, r) => n + r.counts.failed, 0)),
1005
+ String(vm.rows.reduce((n, r) => n + r.counts.stopped, 0)),
1006
+ String(vm.rows.reduce((n, r) => n + r.totals.turns, 0)),
1007
+ formatTokens(vm.rows.reduce((n, r) => n + r.totals.input, 0)),
1008
+ formatTokens(vm.rows.reduce((n, r) => n + r.totals.output, 0)),
1009
+ cost(vm.rows.reduce((n, r) => n + r.totals.cost, 0)),
1010
+ String(vm.rows.reduce((n, r) => n + r.findings, 0)),
1011
+ String(vm.rows.reduce((n, r) => n + r.acceptedArtifacts, 0)),
1012
+ "", "",
1013
+ ];
1014
+ const body =
1015
+ swarmHeader(vm) +
1016
+ `<h2>hordes</h2>` +
1017
+ table(SWARM_HORDE_HEADERS, [...vm.rows.map(swarmHordeRow), totals], "no hordes started") +
1018
+ `<h2>files</h2>${swarmFiles(vm)}`;
1019
+ return page(`swarm ${vm.id}`, [{ text: "runs", href: "/" }, { text: `swarm ${vm.id}` }], body, ctx);
1020
+ }
1021
+
1022
+ function swarmGraphHref(swarmId: string, view: SwarmGraphVm["view"]): string {
1023
+ const seconds = (ms: number): string | undefined => (ms > 0 ? String(Math.round(ms / 1000)) : undefined);
1024
+ return `${swarmHref(swarmId)}/graph${query({ w: String(view.width), from: seconds(view.from), to: seconds(view.to) })}`;
1025
+ }
1026
+
1027
+ /** One lane per horde with its phases, and the merge step in the swarm lane. */
1028
+ export function renderSwarmGraph(vm: SwarmGraphVm, ctx: RenderContext): string {
1029
+ const { graph, view } = vm;
1030
+ const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: `swarm ${vm.swarmId}`, href: swarmHref(vm.swarmId) }, { text: "graph" }];
1031
+ if (vm.empty) return page(`swarm graph ${vm.swarmId}`, crumbs, "<p>no events recorded for this swarm's hordes</p>", ctx);
1032
+ const widths = GRAPH_WIDTHS.map((width) =>
1033
+ width === view.width ? String(width) : link(swarmGraphHref(vm.swarmId, { ...view, width }), String(width)),
1034
+ );
1035
+ const summary =
1036
+ `${graph.lanes.length - 1} hordes, ${graph.nodes.length} lifecycle marks, ${formatElapsed(graph.end - graph.start)} elapsed; ` +
1037
+ "each horde's own graph is on its run page";
1038
+ const body =
1039
+ `<p>${e(summary)}</p>` +
1040
+ `<p>width: ${widths.join(" ")}</p>` +
1041
+ `<div class="graph">${renderRunGraphSvg(graph, view)}</div>`;
1042
+ return page(`swarm graph ${vm.swarmId}`, crumbs, body, ctx);
1043
+ }