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/README.md +42 -10
- package/docs/REFERENCE.md +141 -27
- package/package.json +1 -1
- package/src/args.ts +125 -50
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +390 -95
- package/src/dashboard.ts +205 -96
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +62 -3
- package/src/prompt.ts +102 -21
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +32 -15
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +30 -21
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +36 -25
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +77 -10
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +70 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +16 -9
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +206 -41
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +13 -6
- package/src/widget.ts +80 -8
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
|
-
|
|
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()
|
|
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?.()
|
|
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
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
303
|
+
pattern: /^\/$/, scope: "project", page: "overview",
|
|
233
304
|
live: () => true,
|
|
234
|
-
load: (src
|
|
235
|
-
render:
|
|
305
|
+
load: (src) => listRuns(src),
|
|
306
|
+
render: renderRunIndex,
|
|
236
307
|
},
|
|
237
308
|
{
|
|
238
|
-
pattern: /^\/
|
|
239
|
-
live: () =>
|
|
240
|
-
load: (src,
|
|
241
|
-
|
|
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: /^\/
|
|
246
|
-
live: () =>
|
|
247
|
-
load: (src,
|
|
248
|
-
|
|
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: /^\/
|
|
258
|
-
live: () =>
|
|
259
|
-
load: (src) =>
|
|
260
|
-
render:
|
|
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: /^\/
|
|
264
|
-
live: () =>
|
|
265
|
-
load: (src,
|
|
266
|
-
|
|
267
|
-
|
|
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: /^\/
|
|
273
|
-
live: () =>
|
|
274
|
-
load: (src) =>
|
|
275
|
-
render:
|
|
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
|
-
/**
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
return {
|
|
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
|
-
...
|
|
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 = {
|
|
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, ...
|
|
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
|
-
|
|
157
|
-
|
|
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)
|
|
161
|
-
|
|
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
|
|
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
|
}
|
package/src/ledger/facts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
package/src/ledger/files.ts
CHANGED
|
@@ -79,7 +79,12 @@ function writeIfMissing(filePath: string, content: string): void {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function initHordeDir(cwd: string): string {
|
|
82
|
-
|
|
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);
|