agent-horde 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dashboard.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import * as http from "node:http";
2
2
  import {
3
3
  DashboardError,
4
- latestRunId,
5
4
  listRunSessions,
6
5
  listRuns,
7
6
  loadBoard,
@@ -18,10 +17,22 @@ import {
18
17
  loadResources,
19
18
  loadSessionEntry,
20
19
  loadSessionTranscript,
20
+ loadSwarm,
21
+ loadSwarmBoard,
22
+ loadSwarmClaims,
23
+ loadSwarmCollaboration,
24
+ loadSwarmFacts,
25
+ loadSwarmFile,
26
+ loadSwarmFindings,
27
+ loadSwarmGraph,
28
+ loadSwarmIdeas,
29
+ loadSwarmResources,
21
30
  loadTranscript,
22
31
  loadTranscriptEntry,
23
32
  MEMBER_RE,
24
- RUN_ID_RE,
33
+ requireRun,
34
+ swarmHordes,
35
+ swarmOfRun,
25
36
  } from "./dashboard-data.js";
26
37
  import type { DashboardSource, GraphView } from "./dashboard-data.js";
27
38
  import {
@@ -42,10 +53,19 @@ import {
42
53
  renderRunOverview,
43
54
  renderResources,
44
55
  renderSessions,
56
+ renderSwarm,
57
+ renderSwarmBoard,
58
+ renderSwarmClaims,
59
+ renderSwarmCollaboration,
60
+ renderSwarmFacts,
61
+ renderSwarmFindings,
62
+ renderSwarmGraph,
63
+ renderSwarmIdeas,
64
+ renderSwarmResources,
45
65
  renderTranscript,
46
66
  renderTranscriptEntry,
47
67
  } from "./dashboard-html.js";
48
- import type { RenderContext } from "./dashboard-html.js";
68
+ import type { PageKind, RenderContext, Scope } from "./dashboard-html.js";
49
69
  import { collect, exportDashboard } from "./dashboard-export.js";
50
70
  import type { ExportPage } from "./dashboard-export.js";
51
71
 
@@ -72,6 +92,10 @@ interface Route {
72
92
  live: (vm: unknown, src: DashboardSource, params: string[], query: URLSearchParams) => boolean;
73
93
  load: (src: DashboardSource, params: string[], query: URLSearchParams) => unknown;
74
94
  render: (vm: never, ctx: RenderContext) => string;
95
+ /** What the page is about; params[0] is the run or swarm id for those scopes. */
96
+ scope: "project" | "run" | "swarm";
97
+ /** Which page-row item the page is, so the nav can mark it and horde tabs can keep it. */
98
+ page: PageKind;
75
99
  /** Serve the renderer's output as text/plain instead of a page. */
76
100
  text?: boolean;
77
101
  }
@@ -103,12 +127,6 @@ function queryFrom(query: URLSearchParams): number | "last" {
103
127
  return query.get("from") === "last" ? "last" : queryInt(query, "from", 0, 999_999_999, 0);
104
128
  }
105
129
 
106
- function queryRun(query: URLSearchParams): string {
107
- const value = query.get("run") ?? "all";
108
- if (value !== "all" && !RUN_ID_RE.test(value)) throw new DashboardError(400, `invalid run: ${value}`);
109
- return value;
110
- }
111
-
112
130
  function queryOwner(query: URLSearchParams): string | undefined {
113
131
  const value = query.get("owner");
114
132
  if (value === null || value === "") return undefined;
@@ -116,17 +134,39 @@ function queryOwner(query: URLSearchParams): string | undefined {
116
134
  return value;
117
135
  }
118
136
 
137
+ function queryOrder(query: URLSearchParams): "newest" | "oldest" {
138
+ return queryEnum(query, "order", ["newest", "oldest"] as const, "newest");
139
+ }
140
+
141
+ function queryView(query: URLSearchParams): "list" | "clusters" {
142
+ return queryEnum(query, "view", ["list", "clusters"] as const, "list");
143
+ }
144
+
119
145
  function isActive(src: DashboardSource, runId: string | undefined): boolean {
120
- return runId !== undefined && src.getActive()?.id === runId;
146
+ return runId !== undefined && src.getActive().some((run) => run.id === runId);
121
147
  }
122
148
 
123
149
  function isFinalizing(src: DashboardSource, runId: string | undefined): boolean {
124
- return runId !== undefined && src.getFinalizing?.()?.id === runId;
150
+ return runId !== undefined && (src.getFinalizing?.() ?? []).some((run) => run.id === runId);
151
+ }
152
+
153
+ function isRunLive(src: DashboardSource, runId: string): boolean {
154
+ return isActive(src, runId) || isFinalizing(src, runId);
155
+ }
156
+
157
+ /** A swarm page is live while the swarm is in memory: members running or the merge step pending. */
158
+ function isSwarmLive(src: DashboardSource, swarmId: string | undefined): boolean {
159
+ return swarmId !== undefined && src.getSwarm?.()?.id === swarmId;
160
+ }
161
+
162
+ /** The project ledgers change while a single run (one writing to the project `.horde`) is live. */
163
+ function isProjectLive(src: DashboardSource): boolean {
164
+ return src.getActive().some((run) => run.hordeDir === src.hordeDir);
125
165
  }
126
166
 
127
167
  function memberRunning(src: DashboardSource, runId: string, member: string): boolean {
128
- const state = src.getActive()?.members.find((m) => m.name === member);
129
- return isActive(src, runId) && state !== undefined && (state.status === "running" || state.status === "starting");
168
+ const state = src.getActive().find((run) => run.id === runId)?.members.find((m) => m.name === member);
169
+ return state !== undefined && (state.status === "running" || state.status === "starting");
130
170
  }
131
171
 
132
172
  const p = (params: string[], i: number): string => params[i] ?? "";
@@ -142,84 +182,112 @@ function graphView(query: URLSearchParams): GraphView {
142
182
  };
143
183
  }
144
184
 
145
- const ROUTES: Route[] = [
146
- {
147
- pattern: /^\/$/,
148
- live: () => true,
149
- load: (src) => listRuns(src),
150
- render: renderRunIndex,
151
- },
152
- {
153
- pattern: /^\/runs\/([^/]+)$/,
154
- live: (_vm, src, params) => isActive(src, p(params, 0)) || isFinalizing(src, p(params, 0)),
155
- load: (src, params) => loadRunOverview(src, p(params, 0)),
156
- render: renderRunOverview,
157
- },
158
- {
159
- pattern: /^\/runs\/([^/]+)\/collaboration$/,
160
- live: (_vm, src, params) => isActive(src, p(params, 0)),
161
- load: (src, params, query) => loadCollaboration(src, p(params, 0), queryInt(query, "from", 0, 999_999_999, 0)),
162
- render: renderCollaboration,
163
- },
164
- {
165
- pattern: /^\/runs\/([^/]+)\/graph$/,
166
- live: (_vm, src, params) => isActive(src, p(params, 0)) || isFinalizing(src, p(params, 0)),
167
- load: (src, params, query) => loadRunGraph(src, p(params, 0), graphView(query)),
168
- render: renderGraph,
169
- },
170
- {
171
- pattern: /^\/runs\/([^/]+)\/graph\.mmd$/,
172
- live: () => false,
173
- load: (src, params) => loadRunGraph(src, p(params, 0), { width: GRAPH_WIDTH_DEFAULT, from: 0, to: 0 }),
174
- render: renderGraphMermaid,
175
- text: true,
176
- },
185
+ const LEDGERS = ["ideas", "board", "findings", "claims", "facts", "resources"] as const;
186
+ type Ledger = (typeof LEDGERS)[number];
187
+
188
+ /** A ledger page for the project (`run` undefined) or for one run, which may be a swarm horde. */
189
+ const LEDGER_LOAD: Record<Ledger, (src: DashboardSource, run: string | undefined, query: URLSearchParams) => unknown> = {
190
+ ideas: (src, run, query) => loadIdeas(src, queryOwner(query), run),
191
+ board: (src, run, query) => loadBoard(src, run ?? "all", queryOrder(query)),
192
+ findings: (src, run, query) => loadFindings(src, run ?? "all", queryView(query), queryIds(query, "open")),
193
+ claims: (src, run) => loadClaims(src, run),
194
+ facts: (src, run) => loadFacts(src, run),
195
+ resources: (src, run) => loadResources(src, run),
196
+ };
197
+
198
+ const SWARM_LEDGER_LOAD: Record<Ledger, (src: DashboardSource, swarmId: string, query: URLSearchParams) => unknown> = {
199
+ ideas: (src, swarmId, query) => loadSwarmIdeas(src, swarmId, queryOwner(query)),
200
+ board: (src, swarmId, query) => loadSwarmBoard(src, swarmId, queryOrder(query)),
201
+ findings: (src, swarmId, query) => loadSwarmFindings(src, swarmId, queryView(query), queryIds(query, "open")),
202
+ claims: (src, swarmId) => loadSwarmClaims(src, swarmId),
203
+ facts: (src, swarmId) => loadSwarmFacts(src, swarmId),
204
+ resources: (src, swarmId) => loadSwarmResources(src, swarmId),
205
+ };
206
+
207
+ const LEDGER_RENDER: Record<Ledger, Route["render"]> = {
208
+ ideas: renderIdeas, board: renderBoard, findings: renderFindings, claims: renderClaims, facts: renderFacts, resources: renderResources,
209
+ };
210
+
211
+ const SWARM_LEDGER_RENDER: Record<Ledger, Route["render"]> = {
212
+ ideas: renderSwarmIdeas, board: renderSwarmBoard, findings: renderSwarmFindings,
213
+ claims: renderSwarmClaims, facts: renderSwarmFacts, resources: renderSwarmResources,
214
+ };
215
+
216
+ /** Every ledger page in its three scopes: `/<ledger>`, `/runs/<id>/<ledger>`, `/swarms/<id>/<ledger>`. */
217
+ function ledgerRoutes(): Route[] {
218
+ return LEDGERS.flatMap((kind): Route[] => [
219
+ {
220
+ pattern: new RegExp(`^/${kind}$`), scope: "project", page: kind,
221
+ live: (_vm, src) => isProjectLive(src),
222
+ load: (src, _params, query) => LEDGER_LOAD[kind](src, undefined, query),
223
+ render: LEDGER_RENDER[kind],
224
+ },
225
+ {
226
+ pattern: new RegExp(`^/runs/([^/]+)/${kind}$`), scope: "run", page: kind,
227
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
228
+ load: (src, params, query) => {
229
+ requireRun(src, p(params, 0));
230
+ return LEDGER_LOAD[kind](src, p(params, 0), query);
231
+ },
232
+ render: LEDGER_RENDER[kind],
233
+ },
234
+ {
235
+ pattern: new RegExp(`^/swarms/([^/]+)/${kind}$`), scope: "swarm", page: kind,
236
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
237
+ load: (src, params, query) => SWARM_LEDGER_LOAD[kind](src, p(params, 0), query),
238
+ render: SWARM_LEDGER_RENDER[kind],
239
+ },
240
+ ]);
241
+ }
242
+
243
+ /** Pages that exist for one run only: members, logs, transcripts, sessions, run files. */
244
+ const RUN_DETAIL_ROUTES: Route[] = [
177
245
  {
178
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)$/,
246
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)$/, scope: "run", page: "other",
179
247
  live: (_vm, src, params) => memberRunning(src, p(params, 0), p(params, 1)),
180
248
  load: (src, params, query) => loadMemberDetail(src, p(params, 0), p(params, 1), queryInt(query, "tail", 1, 2000, 60)),
181
249
  render: renderMemberDetail,
182
250
  },
183
251
  {
184
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/log$/,
252
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/log$/, scope: "run", page: "other",
185
253
  live: () => false,
186
254
  load: (src, params, query) => loadMemberLog(src, p(params, 0), p(params, 1), queryInt(query, "tail", 0, 2000, 500)),
187
255
  render: renderMemberLog,
188
256
  },
189
257
  {
190
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript$/,
258
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript$/, scope: "run", page: "other",
191
259
  live: (_vm, src, params, query) => query.get("from") === "last" && memberRunning(src, p(params, 0), p(params, 1)),
192
260
  load: (src, params, query) =>
193
261
  loadTranscript(src, p(params, 0), p(params, 1), queryFrom(query), queryInt(query, "n", 1, 200, 50)),
194
262
  render: renderTranscript,
195
263
  },
196
264
  {
197
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript\/(\d{1,9})$/,
265
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript\/(\d{1,9})$/, scope: "run", page: "other",
198
266
  live: () => false,
199
267
  load: (src, params) => loadTranscriptEntry(src, p(params, 0), p(params, 1), Number(p(params, 2))),
200
268
  render: renderTranscriptEntry,
201
269
  },
202
270
  {
203
- pattern: /^\/runs\/([^/]+)\/sessions$/,
271
+ pattern: /^\/runs\/([^/]+)\/sessions$/, scope: "run", page: "other",
204
272
  live: (_vm, src, params) => isActive(src, p(params, 0)),
205
273
  load: (src, params) => listRunSessions(src, p(params, 0)),
206
274
  render: renderSessions,
207
275
  },
208
276
  {
209
- pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)$/,
277
+ pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)$/, scope: "run", page: "other",
210
278
  live: () => false,
211
279
  load: (src, params, query) =>
212
280
  loadSessionTranscript(src, p(params, 0), p(params, 1), queryFrom(query), queryInt(query, "n", 1, 200, 50)),
213
281
  render: renderTranscript,
214
282
  },
215
283
  {
216
- pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)\/(\d{1,9})$/,
284
+ pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)\/(\d{1,9})$/, scope: "run", page: "other",
217
285
  live: () => false,
218
286
  load: (src, params) => loadSessionEntry(src, p(params, 0), p(params, 1), Number(p(params, 2))),
219
287
  render: renderTranscriptEntry,
220
288
  },
221
289
  {
222
- pattern: /^\/runs\/([^/]+)\/files\/([^/]+)$/,
290
+ pattern: /^\/runs\/([^/]+)\/files\/([^/]+)$/, scope: "run", page: "other",
223
291
  live: () => false,
224
292
  load: (src, params) => {
225
293
  const vm = loadRunFile(src, p(params, 0), p(params, 1));
@@ -228,52 +296,70 @@ const ROUTES: Route[] = [
228
296
  },
229
297
  render: renderFile,
230
298
  },
299
+ ];
300
+
301
+ const ROUTES: Route[] = [
231
302
  {
232
- pattern: /^\/ideas$/,
303
+ pattern: /^\/$/, scope: "project", page: "overview",
233
304
  live: () => true,
234
- load: (src, _params, query) => loadIdeas(src, queryOwner(query)),
235
- render: renderIdeas,
305
+ load: (src) => listRuns(src),
306
+ render: renderRunIndex,
236
307
  },
237
308
  {
238
- pattern: /^\/board$/,
239
- live: () => true,
240
- load: (src, _params, query) =>
241
- loadBoard(src, queryRun(query), queryEnum(query, "order", ["newest", "oldest"] as const, "newest")),
242
- render: renderBoard,
309
+ pattern: /^\/runs\/([^/]+)$/, scope: "run", page: "overview",
310
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
311
+ load: (src, params) => loadRunOverview(src, p(params, 0)),
312
+ render: renderRunOverview,
243
313
  },
244
314
  {
245
- pattern: /^\/findings$/,
246
- live: () => true,
247
- load: (src, _params, query) =>
248
- loadFindings(
249
- src,
250
- queryRun(query),
251
- queryEnum(query, "view", ["list", "clusters"] as const, "list"),
252
- queryIds(query, "open"),
253
- ),
254
- render: renderFindings,
315
+ pattern: /^\/runs\/([^/]+)\/collaboration$/, scope: "run", page: "collaboration",
316
+ live: (_vm, src, params) => isActive(src, p(params, 0)),
317
+ load: (src, params, query) => loadCollaboration(src, p(params, 0), queryInt(query, "from", 0, 999_999_999, 0)),
318
+ render: renderCollaboration,
255
319
  },
256
320
  {
257
- pattern: /^\/claims$/,
258
- live: () => true,
259
- load: (src) => loadClaims(src),
260
- render: renderClaims,
321
+ pattern: /^\/runs\/([^/]+)\/graph$/, scope: "run", page: "graph",
322
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
323
+ load: (src, params, query) => loadRunGraph(src, p(params, 0), graphView(query)),
324
+ render: renderGraph,
261
325
  },
262
326
  {
263
- pattern: /^\/facts$/,
264
- live: () => true,
265
- load: (src, _params, query) => {
266
- const run = queryRun(query);
267
- return loadFacts(src, run === "all" ? undefined : run);
268
- },
269
- render: renderFacts,
327
+ pattern: /^\/runs\/([^/]+)\/graph\.mmd$/, scope: "run", page: "graph",
328
+ live: () => false,
329
+ load: (src, params) => loadRunGraph(src, p(params, 0), { width: GRAPH_WIDTH_DEFAULT, from: 0, to: 0 }),
330
+ render: renderGraphMermaid,
331
+ text: true,
270
332
  },
333
+ ...RUN_DETAIL_ROUTES,
271
334
  {
272
- pattern: /^\/resources$/,
273
- live: () => true,
274
- load: (src) => loadResources(src),
275
- render: renderResources,
335
+ pattern: /^\/swarms\/([^/]+)$/, scope: "swarm", page: "overview",
336
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
337
+ load: (src, params) => loadSwarm(src, p(params, 0)),
338
+ render: renderSwarm,
339
+ },
340
+ {
341
+ pattern: /^\/swarms\/([^/]+)\/collaboration$/, scope: "swarm", page: "collaboration",
342
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
343
+ load: (src, params) => loadSwarmCollaboration(src, p(params, 0)),
344
+ render: renderSwarmCollaboration,
345
+ },
346
+ {
347
+ pattern: /^\/swarms\/([^/]+)\/graph$/, scope: "swarm", page: "graph",
348
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
349
+ load: (src, params, query) => loadSwarmGraph(src, p(params, 0), graphView(query)),
350
+ render: renderSwarmGraph,
276
351
  },
352
+ {
353
+ pattern: /^\/swarms\/([^/]+)\/files\/([^/]+)$/, scope: "swarm", page: "other",
354
+ live: () => false,
355
+ load: (src, params) => {
356
+ const vm = loadSwarmFile(src, p(params, 0), p(params, 1));
357
+ if (!vm.present) throw new DashboardError(404, `${vm.name}.md has not been written yet`);
358
+ return vm;
359
+ },
360
+ render: renderFile,
361
+ },
362
+ ...ledgerRoutes(),
277
363
  ];
278
364
 
279
365
  function baseHeaders(): Record<string, string> {
@@ -329,12 +415,36 @@ function routeMatch(pathname: string): { route: Route; params: string[] } | unde
329
415
  return undefined;
330
416
  }
331
417
 
332
- /** The run this page belongs to, and the run the nav's collaboration and graph links target. */
333
- function runContext(src: DashboardSource, url: URL, params: string[]): Pick<RenderContext, "runId" | "navRun"> {
334
- const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
335
- const filterRun = url.searchParams.get("run");
336
- const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
337
- return { runId, navRun };
418
+ /**
419
+ * The scope the route declares, filled in from its id. A horde run also carries its swarm's hordes
420
+ * for the tabs; a swarm that cannot be read leaves the run without tabs rather than failing the page.
421
+ */
422
+ function scopeOf(src: DashboardSource, route: Route, params: string[]): Scope {
423
+ if (route.scope === "project") return { kind: "project" };
424
+ const id = p(params, 0);
425
+ if (route.scope === "swarm") return { kind: "swarm", swarmId: id, hordes: swarmHordes(src, id) };
426
+ const swarm = swarmOfRun(id);
427
+ if (!swarm) return { kind: "run", runId: id };
428
+ try {
429
+ return { kind: "run", runId: id, swarm: { id: swarm.swarmId, hordes: swarmHordes(src, swarm.swarmId) } };
430
+ } catch {
431
+ return { kind: "run", runId: id };
432
+ }
433
+ }
434
+
435
+ /** The page's place in the nav: its scope and which page-row item it is. */
436
+ /** A swarm (not a swarm of one, which writes the project ledgers) the parent still holds in memory. */
437
+ function liveSwarmOf(src: DashboardSource): RenderContext["liveSwarm"] {
438
+ const swarm = src.getSwarm?.();
439
+ if (!swarm || swarm.dir === undefined) return undefined;
440
+ const running = src.getActive().some((run) => swarmOfRun(run.id)?.swarmId === swarm.id);
441
+ return { id: swarm.id, finishing: !running };
442
+ }
443
+
444
+ /** The page's place in the nav: its scope, which page-row item it is, and a live swarm for project pages. */
445
+ function pageContext(src: DashboardSource, route: Route, params: string[]): Pick<RenderContext, "scope" | "pageKind" | "liveSwarm"> {
446
+ const liveSwarm = route.scope === "project" ? liveSwarmOf(src) : undefined;
447
+ return { scope: scopeOf(src, route, params), pageKind: route.page, liveSwarm };
338
448
  }
339
449
 
340
450
  /** The export costs seconds of work per request, so pages from other sites may not start one. */
@@ -355,7 +465,7 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
355
465
  const on = live && refreshParam !== "0";
356
466
  const pageCtx: RenderContext = {
357
467
  ...ctx,
358
- ...runContext(src, url, params),
468
+ ...pageContext(src, route, params),
359
469
  refresh: live ? { on, toggleHref: refreshToggle(url, on), seconds: REFRESH_SECONDS } : undefined,
360
470
  };
361
471
  const body = route.render(vm as never, pageCtx);
@@ -365,13 +475,13 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
365
475
 
366
476
  /** One page of the static export; errors become pages so no exported link dangles. */
367
477
  function exportPage(src: DashboardSource, url: URL): ExportPage {
368
- const ctx: RenderContext = { activeId: src.getActive()?.id, href: url.pathname + url.search, export: true };
478
+ const ctx: RenderContext = { href: url.pathname + url.search, export: true };
369
479
  try {
370
480
  const matched = routeMatch(url.pathname);
371
481
  if (!matched) throw new DashboardError(404, `no such page: ${url.pathname}`);
372
482
  const { route, params } = matched;
373
483
  const vm = route.load(src, params, url.searchParams);
374
- return { body: route.render(vm as never, { ...ctx, ...runContext(src, url, params) }), text: route.text };
484
+ return { body: route.render(vm as never, { ...ctx, ...pageContext(src, route, params) }), text: route.text };
375
485
  } catch (error) {
376
486
  const status = error instanceof DashboardError ? error.status : 500;
377
487
  const message = error instanceof DashboardError ? error.message : `dashboard error: ${error instanceof Error ? error.message : String(error)}`;
@@ -414,7 +524,6 @@ function exportResponse(src: DashboardSource, url: URL, ctx: RenderContext): Das
414
524
  export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined, fetchSite?: string): DashboardResponse {
415
525
  const json = url.pathname === "/api" || url.pathname.startsWith("/api/");
416
526
  const ctx: RenderContext = {
417
- activeId: src.getActive()?.id,
418
527
  origin: host ? `http://${host}` : undefined,
419
528
  href: url.pathname + url.search,
420
529
  };
@@ -79,7 +79,12 @@ function writeIfMissing(filePath: string, content: string): void {
79
79
  }
80
80
 
81
81
  export function initHordeDir(cwd: string): string {
82
- const paths = hordePaths(path.join(cwd, ".horde"));
82
+ return initHordeDirAt(path.join(cwd, ".horde"));
83
+ }
84
+
85
+ /** Create a complete horde directory (ledgers, lock, runs/) at `hordeDir`; existing ledgers are kept. */
86
+ export function initHordeDirAt(hordeDir: string): string {
87
+ const paths = hordePaths(hordeDir);
83
88
  fs.mkdirSync(paths.runs, { recursive: true });
84
89
  writeIfMissing(paths.gitignore, "runs/\n");
85
90
  writeIfMissing(paths.ideas, IDEAS_HEADER);
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { collaborationState } from "./collaboration.js";
4
- import type { PeerMessage, ReviewRecord } from "./collaboration.js";
4
+ import type { ArtifactRecord, PeerMessage, ReviewRecord } from "./collaboration.js";
5
5
  import { formatClock } from "../format.js";
6
6
  import { RUN_HEADING } from "./board.js";
7
7
  import { registerConcern } from "./concerns.js";
@@ -558,28 +558,47 @@ export function supersededCitations(text: string, findings: FindingEntry[]): str
558
558
  .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
559
559
  }
560
560
 
561
- /** The current revision of every live finding a text cites, pinned so a later amend is visible. */
562
- export function answerCitations(findings: FindingEntry[], text: string): Record<string, string> {
561
+ /**
562
+ * The current revision of every live finding and published artifact a text cites, pinned so a
563
+ * later amend or republish is visible.
564
+ */
565
+ export function answerCitations(findings: FindingEntry[], text: string, artifacts: ArtifactRecord[] = []): Record<string, string> {
563
566
  const cites: Record<string, string> = {};
564
567
  for (const finding of findings) {
565
568
  if (finding.supersededBy === undefined && new RegExp(`\\bF${finding.id}\\b`).test(text)) cites[`F${finding.id}`] = findingRevision(finding);
566
569
  }
570
+ for (const artifact of artifacts) {
571
+ if (new RegExp(`\\b${artifact.id}\\b`).test(text)) cites[artifact.id] = artifact.hash;
572
+ }
567
573
  return cites;
568
574
  }
569
575
 
570
576
  /**
571
- * Citations an answer can no longer stand on: findings superseded since it was written, and
572
- * findings amended since the revisions it pinned. Both ship a stale reading as the result.
577
+ * Citations an answer can no longer stand on: findings superseded since it was written, findings
578
+ * amended and artifacts republished since the revisions it pinned. Each ships a stale reading as
579
+ * the result.
573
580
  */
574
- export function staleAnswerCitations(findings: FindingEntry[], text: string, cites: Record<string, string> = {}): string[] {
581
+ export function staleAnswerCitations(
582
+ findings: FindingEntry[],
583
+ text: string,
584
+ cites: Record<string, string> = {},
585
+ artifacts: ArtifactRecord[] = [],
586
+ ): string[] {
575
587
  const stale = supersededCitations(text, findings).map((citation) => `superseded ${citation}`);
576
588
  for (const [target, revision] of Object.entries(cites)) {
577
589
  const finding = findings.find((entry) => `F${entry.id}` === target);
578
590
  if (finding && finding.supersededBy === undefined && findingRevision(finding) !== revision) stale.push(`amended ${target}`);
591
+ const artifact = artifacts.find((entry) => entry.id === target);
592
+ if (artifact && artifact.hash !== revision) stale.push(`republished ${target}`);
579
593
  }
580
594
  return stale;
581
595
  }
582
596
 
597
+ /** The answer's stale citations against this run's current findings and artifacts. */
598
+ export function staleAnswerFor(hordeDir: string, runId: string, text: string, cites?: Record<string, string>): string[] {
599
+ return staleAnswerCitations(listFindingsForRun(hordeDir, runId), text, cites, collaborationState(hordeDir, runId).artifacts);
600
+ }
601
+
583
602
  /** A body line that reads as a verification note would move the subject boundary the revision hashes. */
584
603
  function neutralizeNotes(text: string): string {
585
604
  return text.replace(/^((?:Verified|Reviewed) by )/gm, "\\$1");
package/src/member.ts CHANGED
@@ -77,6 +77,15 @@ export interface MemberReset {
77
77
  tokens: number;
78
78
  }
79
79
 
80
+ /** Another horde of the same swarm, whose ledgers a member may read but never write. */
81
+ export interface SiblingHorde {
82
+ /** `h1`, `h2`, ... */
83
+ label: string;
84
+ hordeDir: string;
85
+ /** That horde's workstream, when the swarm partitioned the task. */
86
+ slice?: string | undefined;
87
+ }
88
+
80
89
  export interface MemberRunInfo {
81
90
  id: string;
82
91
  hordeDir: string;
@@ -85,6 +94,13 @@ export interface MemberRunInfo {
85
94
  count: number;
86
95
  /** True when started with `--until-stop`: the run continues until the user stops it. */
87
96
  untilStop?: boolean | undefined;
97
+ /** 1-based horde index in the swarm and the swarm's size; a single run is horde 1 of 1. */
98
+ horde?: number | undefined;
99
+ hordes?: number | undefined;
100
+ /** This horde's workstream, told to every member, when the swarm partitioned the task. */
101
+ slice?: string | undefined;
102
+ /** The other hordes of the swarm. */
103
+ siblings?: SiblingHorde[] | undefined;
88
104
  }
89
105
 
90
106
  interface SpawnMemberInput {
@@ -294,9 +310,21 @@ export const UNTIL_STOP_NOTE =
294
310
  "SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
295
311
  "materially better one exists.";
296
312
 
313
+ /** What a member of a partitioned swarm is told about its horde's workstream. */
314
+ export function sliceNote(run: MemberRunInfo): string | undefined {
315
+ if (!run.slice || !run.horde || !run.hordes || run.hordes < 2) return undefined;
316
+ return (
317
+ `Your horde is h${run.horde} of ${run.hordes} hordes working the same task in parallel, each in its own ` +
318
+ `ledgers. Your horde owns workstream ${run.horde}: "${run.slice}". Work only on your horde's workstream; ` +
319
+ "do not duplicate another horde's."
320
+ );
321
+ }
322
+
297
323
  /** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
298
324
  function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
299
325
  const parts = [`You are ${member.name}.`];
326
+ const slice = sliceNote(run);
327
+ if (slice) parts.push(slice);
300
328
  if (role) parts.push(role);
301
329
  if (member.resetNote) parts.push(member.resetNote);
302
330
  if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
@@ -319,6 +347,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
319
347
  }
320
348
 
321
349
  export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
350
+ const siblings = input.run.siblings ?? [];
322
351
  return childEnv({
323
352
  PI_HORDE_MEMBER: "1",
324
353
  PI_HORDE_DIR: input.run.hordeDir,
@@ -326,9 +355,27 @@ export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
326
355
  PI_HORDE_RUN: input.run.id,
327
356
  PI_HORDE_ROLE: input.role ?? "",
328
357
  PI_HORDE_ATTEMPT: input.member.attempt ??= randomUUID(),
358
+ PI_HORDE_SIBLINGS: siblings.length > 0 ? JSON.stringify(siblings.map((s) => ({ label: s.label, hordeDir: s.hordeDir }))) : "",
329
359
  });
330
360
  }
331
361
 
362
+ /** The sibling hordes a member process was given, from `PI_HORDE_SIBLINGS`; empty outside a swarm. */
363
+ export function parseSiblings(raw: string | undefined): SiblingHorde[] {
364
+ if (!raw) return [];
365
+ try {
366
+ const items = JSON.parse(raw) as unknown;
367
+ if (!Array.isArray(items)) return [];
368
+ return items
369
+ .filter((item): item is { label: string; hordeDir: string } =>
370
+ typeof item === "object" && item !== null &&
371
+ typeof (item as { label?: unknown }).label === "string" &&
372
+ typeof (item as { hordeDir?: unknown }).hordeDir === "string")
373
+ .map((item) => ({ label: item.label, hordeDir: item.hordeDir }));
374
+ } catch {
375
+ return [];
376
+ }
377
+ }
378
+
332
379
  export function splitNdjson(buffer: string, onLine: (line: string) => void): string {
333
380
  const lines = buffer.split("\n");
334
381
  const remainder = lines.pop() ?? "";