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/README.md +22 -3
- package/docs/REFERENCE.md +107 -17
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/citations.ts +16 -5
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +379 -92
- package/src/dashboard.ts +205 -96
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +25 -6
- package/src/member.ts +47 -0
- package/src/prompt.ts +37 -6
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +26 -14
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +34 -25
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +136 -24
- package/src/run/setup.ts +35 -25
- package/src/run/solved.ts +21 -8
- package/src/run/supervise.ts +3 -1
- 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 +66 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +7 -7
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +67 -38
- package/src/tool/collaboration-actions.ts +31 -6
- package/src/tool/register.ts +3 -1
- 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
|
};
|
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);
|
package/src/ledger/findings.ts
CHANGED
|
@@ -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
|
-
/**
|
|
562
|
-
|
|
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,
|
|
572
|
-
*
|
|
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(
|
|
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() ?? "";
|