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.
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
  };
@@ -46,6 +46,8 @@ export interface ArtifactRecord {
46
46
  snapshot: string;
47
47
  criteria: string;
48
48
  dependencies: Array<{ target: string; revision: string }>;
49
+ /** The owner's statement of what a republish changed, required once other artifacts pin it. */
50
+ changeNote?: string;
49
51
  /** Projected from the publishing event; absent in hand-built records. */
50
52
  publishedAt?: string;
51
53
  }
@@ -57,9 +59,14 @@ export interface ReviewRecord {
57
59
  verdict: "supports" | "partially-supports" | "refutes" | "inconclusive";
58
60
  support: string;
59
61
  checked: string;
62
+ /** Gaps inside the target's stated claim or criteria; non-empty keeps acceptance open. */
60
63
  unchecked: string;
64
+ /** Disclosures outside the criteria; recorded on the acceptance as limits, never blockers. */
65
+ limits?: string;
61
66
  text: string;
62
67
  evidence: string[];
68
+ /** The reviewer's earlier review of a previous revision that this one carries forward. */
69
+ reattests?: string;
63
70
  }
64
71
  export interface ResourceContract {
65
72
  target: string;
@@ -153,12 +160,35 @@ export function blockingMessages(state: CollaborationState): PeerMessage[] {
153
160
  return state.messages.filter((m) => m.blocking && m.state !== "resolved");
154
161
  }
155
162
 
156
- export function artifactAccepted(state: CollaborationState, artifact: ArtifactRecord): boolean {
157
- if (blockingMessages(state).some((m) => m.target === artifact.id)) return false;
158
- const reviews = state.reviews.filter((r) => r.target === artifact.id && r.revision === artifact.hash);
163
+ /** Each non-owner's latest review of the artifact's current revision. */
164
+ export function latestArtifactReviews(state: CollaborationState, artifact: ArtifactRecord): ReviewRecord[] {
159
165
  const latest = new Map<string, ReviewRecord>();
160
- for (const review of reviews) latest.set(review.actor, review);
161
- if ([...latest.values()].some((r) => r.verdict === "refutes")) return false;
166
+ for (const review of state.reviews) {
167
+ if (review.target === artifact.id && review.revision === artifact.hash && review.actor !== artifact.owner) latest.set(review.actor, review);
168
+ }
169
+ return [...latest.values()];
170
+ }
171
+
172
+ function acceptingReview(artifact: ArtifactRecord, review: ReviewRecord): boolean {
162
173
  const methods = artifact.kind === "document" ? ["sourced", "derived", "tested", "reproduced"] : ["tested", "reproduced"];
163
- return [...latest.values()].some((r) => r.actor !== artifact.owner && r.verdict === "supports" && !r.unchecked && methods.includes(r.support));
174
+ return review.verdict === "supports" && !review.unchecked && methods.includes(review.support);
175
+ }
176
+
177
+ export function artifactAccepted(state: CollaborationState, artifact: ArtifactRecord): boolean {
178
+ if (blockingMessages(state).some((m) => m.target === artifact.id)) return false;
179
+ const latest = latestArtifactReviews(state, artifact);
180
+ if (latest.some((r) => r.verdict === "refutes")) return false;
181
+ return latest.some((r) => acceptingReview(artifact, r));
182
+ }
183
+
184
+ /** Limits the accepting reviews disclosed outside the criteria, each named with its review. */
185
+ export function artifactLimits(state: CollaborationState, artifact: ArtifactRecord): string[] {
186
+ return latestArtifactReviews(state, artifact)
187
+ .filter((r) => acceptingReview(artifact, r) && r.limits?.trim())
188
+ .map((r) => `${r.id} (${r.actor}): ${r.limits?.trim()}`);
189
+ }
190
+
191
+ /** Reviews of the current revision that left in-criteria scope unchecked: coverage debt, named. */
192
+ export function partialArtifactReviews(state: CollaborationState, artifact: ArtifactRecord): ReviewRecord[] {
193
+ return latestArtifactReviews(state, artifact).filter((r) => r.verdict !== "refutes" && r.unchecked.trim());
164
194
  }
@@ -1,8 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import { oneLine } from "../format.js";
3
+ import { listClaims } from "./claims.js";
4
+ import { CLUSTER_THRESHOLD } from "./clusters.js";
3
5
  import { appendEntry, hordePaths, requireText, writeFileAtomic } from "./files.js";
6
+ import { listIdeas } from "./ideas.js";
4
7
  import { withHordeLock } from "./lock.js";
5
- import { bestTextMatch, factAnchors, salientTokens, stripLeadingId } from "./text-match.js";
8
+ import { bestTextMatch, factAnchors, hardOverlap, salientTokens, stripLeadingId, textOverlap } from "./text-match.js";
6
9
 
7
10
  export interface FactEntry {
8
11
  id: number;
@@ -10,6 +13,8 @@ export interface FactEntry {
10
13
  author: string;
11
14
  deltaOf?: number | undefined;
12
15
  corroborators: string[];
16
+ /** The open idea (`#12`) or contested claim (`C3`) that was still auditing the subject when the fact was filed. */
17
+ provisionalOn?: string | undefined;
13
18
  headingLine: number;
14
19
  }
15
20
 
@@ -23,18 +28,58 @@ export function parseFacts(content: string): FactEntry[] {
23
28
  const meta = match[4] ?? "";
24
29
  const delta = /delta=#?T?(\d+)/.exec(meta);
25
30
  const corr = /corr=([^\s]+)/.exec(meta);
31
+ const prov = /prov=([^\s]+)/.exec(meta);
26
32
  facts.push({
27
33
  id: Number(match[1]),
28
34
  text: match[2] ?? "",
29
35
  author: match[3] ?? "",
30
36
  deltaOf: delta ? Number(delta[1]) : undefined,
31
37
  corroborators: corr?.[1] ? corr[1].split(",").map((s) => s.trim()).filter(Boolean) : [],
38
+ provisionalOn: prov?.[1],
32
39
  headingLine: lineIndex,
33
40
  });
34
41
  });
35
42
  return facts;
36
43
  }
37
44
 
45
+ /**
46
+ * A fact asserting what an open idea is still auditing, or what a contested claim disputes, is
47
+ * provisional: it is recorded, but the answer may not quote it as settled until the idea closes
48
+ * or the claim resolves. Returns the subject it is provisional on, if any.
49
+ */
50
+ function provisionalSubject(hordeDir: string, text: string): string | undefined {
51
+ const matches = (candidates: string[]): number | undefined => {
52
+ const match = textOverlap(candidates, text);
53
+ if (!match) return undefined;
54
+ return hardOverlap(match) || Math.max(match.jaccard, match.overlap) >= CLUSTER_THRESHOLD ? match.index : undefined;
55
+ };
56
+ const open = listIdeas(hordeDir).filter((idea) => idea.mark === " " || idea.mark === "~");
57
+ const idea = matches(open.map((entry) => entry.text));
58
+ if (idea !== undefined) return `#${open[idea]?.id}`;
59
+ const contested = listClaims(hordeDir).filter((claim) => claim.state === "contested");
60
+ const claim = matches(contested.map((entry) => entry.statement));
61
+ return claim === undefined ? undefined : `C${contested[claim]?.id}`;
62
+ }
63
+
64
+ /** Whether a fact's provisional subject is still open: an unclosed idea or a still-contested claim. */
65
+ export function factUnsettled(hordeDir: string, fact: FactEntry): boolean {
66
+ if (!fact.provisionalOn) return false;
67
+ if (fact.provisionalOn.startsWith("#")) {
68
+ const idea = listIdeas(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
69
+ return idea !== undefined && (idea.mark === " " || idea.mark === "~");
70
+ }
71
+ const claim = listClaims(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
72
+ return claim?.state === "contested";
73
+ }
74
+
75
+ /** T# ids a text quotes whose provisional subject has not closed, each named with that subject. */
76
+ export function unsettledFactCitations(hordeDir: string, text: string): string[] {
77
+ const cited = new Set([...text.matchAll(/\bT(\d+)\b/g)].map((m) => Number(m[1])));
78
+ return listFacts(hordeDir)
79
+ .filter((fact) => cited.has(fact.id) && factUnsettled(hordeDir, fact))
80
+ .map((fact) => `T${fact.id} (provisional on ${fact.provisionalOn})`);
81
+ }
82
+
38
83
  /** Record a second reporter of the same fact on its heading, mirroring findings. */
39
84
  function addFactCorroborator(content: string, entry: FactEntry, author: string): string {
40
85
  if (entry.author === author || entry.corroborators.includes(author)) return content;
@@ -46,9 +91,10 @@ function addFactCorroborator(content: string, entry: FactEntry, author: string):
46
91
  return lines.join("\n");
47
92
  }
48
93
 
49
- function factEntry(author: string, text: string, id: number, deltaOf?: number): string {
94
+ function factEntry(author: string, text: string, id: number, deltaOf?: number, provisionalOn?: string): string {
50
95
  const delta = deltaOf !== undefined ? ` delta=#T${deltaOf}` : "";
51
- return `\n- [T${id}] ${text} @${author}${delta}\n`;
96
+ const prov = provisionalOn ? ` prov=${provisionalOn}` : "";
97
+ return `\n- [T${id}] ${text} @${author}${delta}${prov}\n`;
52
98
  }
53
99
 
54
100
  interface AddFactResult {
@@ -117,8 +163,9 @@ export async function addFact(
117
163
  if (hit) return accrete(hit);
118
164
  }
119
165
  const id = facts.reduce((max, fact) => Math.max(max, fact.id), 0) + 1;
120
- appendEntry(factsPath, factEntry(author, body, id, deltaOf));
121
- return { fact: { id, text: body, author, deltaOf, corroborators: [], headingLine: -1 } };
166
+ const provisionalOn = provisionalSubject(hordeDir, body);
167
+ appendEntry(factsPath, factEntry(author, body, id, deltaOf, provisionalOn));
168
+ return { fact: { id, text: body, author, deltaOf, corroborators: [], provisionalOn, headingLine: -1 } };
122
169
  });
123
170
  }
124
171
 
@@ -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);