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/args.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export const MAX_MEMBERS = 12;
|
|
2
2
|
|
|
3
|
+
/** Hordes per swarm; `H/M` launches H hordes of M members. */
|
|
4
|
+
export const MAX_HORDES = 4;
|
|
5
|
+
|
|
3
6
|
export const USAGE = [
|
|
4
|
-
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] <count> <prompt>",
|
|
5
|
-
" spawn <count> members with the same prompt",
|
|
7
|
+
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
|
|
8
|
+
" spawn <count> members with the same prompt;",
|
|
9
|
+
" <count> may be H/M: H hordes of M members, the",
|
|
10
|
+
" task partitioned between the hordes by a planner",
|
|
6
11
|
" /agent-horde status [target] show the active run",
|
|
7
12
|
" /agent-horde stop-hard [target] terminate members now (SIGTERM, then SIGKILL)",
|
|
8
13
|
" /agent-horde stop-clean [target] let members wrap up, then reconcile their ideas",
|
|
@@ -11,21 +16,34 @@ export const USAGE = [
|
|
|
11
16
|
" /agent-horde pause [target] suspend members (SIGSTOP)",
|
|
12
17
|
" /agent-horde resume [target] resume suspended members (SIGCONT)",
|
|
13
18
|
` /agent-horde plan [--dashboard] <count> <a; b; ...>`,
|
|
14
|
-
" partition members across workstreams",
|
|
19
|
+
" partition members across workstreams; with H/M",
|
|
20
|
+
" the layers go round-robin to the hordes",
|
|
15
21
|
" /agent-horde help show this text",
|
|
16
22
|
"",
|
|
17
|
-
"target is 'all' (default), 'member-N', 'N',
|
|
18
|
-
|
|
23
|
+
"target is 'all' (default), 'member-N', 'N', 'hN' (one horde), 'hN:member-M', 'hN:M', or a",
|
|
24
|
+
"comma/space separated list; a bare member names that member in every horde.",
|
|
25
|
+
`count is a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES}`,
|
|
19
26
|
"--dashboard serves a read-only localhost dashboard (default port 7433, or",
|
|
20
27
|
"PI_HORDE_DASHBOARD_PORT); --dashboard=<port> picks the port.",
|
|
21
28
|
"--no-verifier skips the default verifier role that runs of 3 or more members assign",
|
|
22
29
|
"to their last member.",
|
|
30
|
+
"--until-stop keeps the whole horde working until you run stop-clean or stop-hard: a",
|
|
31
|
+
"validated solve is banked as the current answer and a member that finishes gets a fresh",
|
|
32
|
+
"session told to add new ideas.",
|
|
23
33
|
].join("\n");
|
|
24
34
|
|
|
25
|
-
/**
|
|
35
|
+
/** One token of a control target: a horde, a member in every horde, or a member of one horde. */
|
|
36
|
+
export interface TargetItem {
|
|
37
|
+
/** 1-based horde index (`h2`); absent for every horde. */
|
|
38
|
+
horde?: number;
|
|
39
|
+
/** `member-N`; absent for every member of the horde. */
|
|
40
|
+
member?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which hordes and members a control command applies to. */
|
|
26
44
|
export type HordeTarget =
|
|
27
45
|
| { kind: "all" }
|
|
28
|
-
| { kind: "
|
|
46
|
+
| { kind: "selection"; items: TargetItem[] };
|
|
29
47
|
|
|
30
48
|
type HordeStopMode = "hard" | "clean";
|
|
31
49
|
|
|
@@ -43,32 +61,66 @@ type HordeArgs =
|
|
|
43
61
|
| { kind: "resume"; target: HordeTarget }
|
|
44
62
|
| {
|
|
45
63
|
kind: "run";
|
|
64
|
+
/** Concurrent hordes; `12` is one horde, `3/12` three. */
|
|
65
|
+
hordes: number;
|
|
66
|
+
/** Members per horde. */
|
|
46
67
|
count: number;
|
|
47
68
|
prompt: string;
|
|
48
69
|
plan?: string[] | undefined;
|
|
49
70
|
dashboard?: DashboardOption | undefined;
|
|
50
71
|
/** Present (true) only when `--no-verifier` was given. */
|
|
51
72
|
noVerifier?: boolean | undefined;
|
|
73
|
+
/** Present (true) only when `--until-stop` was given. */
|
|
74
|
+
untilStop?: boolean | undefined;
|
|
52
75
|
};
|
|
53
76
|
|
|
54
77
|
export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
|
|
55
78
|
|
|
79
|
+
const HORDE_TOKEN = /^h(\d+)$/i;
|
|
80
|
+
const MEMBER_TOKEN = /^(?:member-)?(\d+)$/i;
|
|
81
|
+
|
|
82
|
+
function memberName(token: string): string | undefined {
|
|
83
|
+
const match = MEMBER_TOKEN.exec(token);
|
|
84
|
+
if (!match) return undefined;
|
|
85
|
+
const index = Number(match[1]);
|
|
86
|
+
return index >= 1 && index <= MAX_MEMBERS ? `member-${index}` : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hordeIndex(token: string): number | undefined {
|
|
90
|
+
const match = HORDE_TOKEN.exec(token);
|
|
91
|
+
if (!match) return undefined;
|
|
92
|
+
const index = Number(match[1]);
|
|
93
|
+
return index >= 1 && index <= MAX_HORDES ? index : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `h2`, `h2:member-3`, `h2:3`, `member-3`, or `3`; undefined for anything else. */
|
|
97
|
+
function parseTargetItem(token: string): TargetItem | undefined {
|
|
98
|
+
const colon = token.indexOf(":");
|
|
99
|
+
if (colon === -1) {
|
|
100
|
+
const horde = hordeIndex(token);
|
|
101
|
+
if (horde !== undefined) return { horde };
|
|
102
|
+
const member = memberName(token);
|
|
103
|
+
return member === undefined ? undefined : { member };
|
|
104
|
+
}
|
|
105
|
+
const horde = hordeIndex(token.slice(0, colon));
|
|
106
|
+
const member = memberName(token.slice(colon + 1));
|
|
107
|
+
return horde === undefined || member === undefined ? undefined : { horde, member };
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
function parseTargets(raw: string): HordeTarget | undefined {
|
|
57
111
|
const trimmed = raw.trim();
|
|
58
112
|
if (trimmed === "") return { kind: "all" };
|
|
59
|
-
const
|
|
113
|
+
const items: TargetItem[] = [];
|
|
60
114
|
for (const token of trimmed.split(/[\s,]+/).filter(Boolean)) {
|
|
61
115
|
if (token.toLowerCase() === "all") return { kind: "all" };
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
if (index < 1 || index > MAX_MEMBERS) return undefined;
|
|
66
|
-
names.push(`member-${index}`);
|
|
116
|
+
const item = parseTargetItem(token);
|
|
117
|
+
if (!item) return undefined;
|
|
118
|
+
items.push(item);
|
|
67
119
|
}
|
|
68
|
-
return
|
|
120
|
+
return items.length > 0 ? { kind: "selection", items } : { kind: "all" };
|
|
69
121
|
}
|
|
70
122
|
|
|
71
|
-
const TARGET_ERROR = "invalid target; use all, member-N, or
|
|
123
|
+
const TARGET_ERROR = "invalid target; use all, member-N, N, hN, or hN:member-M";
|
|
72
124
|
|
|
73
125
|
function parseStopRest(
|
|
74
126
|
rest: string,
|
|
@@ -102,6 +154,7 @@ function parseStopRest(
|
|
|
102
154
|
|
|
103
155
|
const DASHBOARD_FLAG = /^--dashboard(?:=(\S*))?(?=\s|$)/;
|
|
104
156
|
const NO_VERIFIER_FLAG = /^--no-verifier(?=\s|$)/;
|
|
157
|
+
const UNTIL_STOP_FLAG = /^--until-stop(?=\s|$)/;
|
|
105
158
|
const DASHBOARD_ERROR = "invalid --dashboard; use --dashboard or --dashboard=<port 1-65535>";
|
|
106
159
|
|
|
107
160
|
/** Launch flags found at one position of the command; `rest` is the remaining text. */
|
|
@@ -109,6 +162,7 @@ interface LaunchFlags {
|
|
|
109
162
|
rest: string;
|
|
110
163
|
dashboard?: DashboardOption | undefined;
|
|
111
164
|
noVerifier?: boolean | undefined;
|
|
165
|
+
untilStop?: boolean | undefined;
|
|
112
166
|
error?: string | undefined;
|
|
113
167
|
}
|
|
114
168
|
|
|
@@ -120,7 +174,7 @@ function parseDashboardFlag(port: string | undefined): DashboardOption | { error
|
|
|
120
174
|
return { port: value };
|
|
121
175
|
}
|
|
122
176
|
|
|
123
|
-
/** Strip leading `--dashboard[=<port>]
|
|
177
|
+
/** Strip leading `--dashboard[=<port>]`, `--no-verifier`, and `--until-stop` tokens, in any order. */
|
|
124
178
|
function takeLaunchFlags(text: string): LaunchFlags {
|
|
125
179
|
const flags: LaunchFlags = { rest: text.trim() };
|
|
126
180
|
for (;;) {
|
|
@@ -138,6 +192,12 @@ function takeLaunchFlags(text: string): LaunchFlags {
|
|
|
138
192
|
flags.rest = flags.rest.slice(noVerifier[0].length).trim();
|
|
139
193
|
continue;
|
|
140
194
|
}
|
|
195
|
+
const untilStop = UNTIL_STOP_FLAG.exec(flags.rest);
|
|
196
|
+
if (untilStop) {
|
|
197
|
+
flags.untilStop = true;
|
|
198
|
+
flags.rest = flags.rest.slice(untilStop[0].length).trim();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
141
201
|
return flags;
|
|
142
202
|
}
|
|
143
203
|
}
|
|
@@ -148,39 +208,68 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
|
|
|
148
208
|
rest: trailing.rest,
|
|
149
209
|
dashboard: leading.dashboard ?? trailing.dashboard,
|
|
150
210
|
noVerifier: leading.noVerifier === true || trailing.noVerifier === true ? true : undefined,
|
|
211
|
+
untilStop: leading.untilStop === true || trailing.untilStop === true ? true : undefined,
|
|
151
212
|
error: leading.error ?? trailing.error,
|
|
152
213
|
};
|
|
153
214
|
}
|
|
154
215
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
216
|
+
const COUNT_ERROR =
|
|
217
|
+
`count must be a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES} hordes`;
|
|
218
|
+
|
|
219
|
+
interface CountedRun {
|
|
220
|
+
hordes: number;
|
|
221
|
+
count: number;
|
|
222
|
+
rest: string;
|
|
223
|
+
flags: LaunchFlags;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Parse `<count> [flags] <tail>`, where count is `M` or `H/M`. */
|
|
227
|
+
function parseCountedRun(text: string, leading: LaunchFlags): CountedRun | { error: string } {
|
|
228
|
+
const match = /^(?:(\d+)\/)?(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
229
|
+
if (!match) return { error: COUNT_ERROR };
|
|
230
|
+
const hordes = match[1] === undefined ? 1 : Number(match[1]);
|
|
231
|
+
const count = Number(match[2]);
|
|
232
|
+
if (count < 1 || count > MAX_MEMBERS || hordes < 1 || hordes > MAX_HORDES) return { error: COUNT_ERROR };
|
|
233
|
+
const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[3] ?? ""));
|
|
167
234
|
if (flags.error) return { error: flags.error };
|
|
168
|
-
return { count, rest: flags.rest, flags };
|
|
235
|
+
return { hordes, count, rest: flags.rest, flags };
|
|
169
236
|
}
|
|
170
237
|
|
|
171
|
-
function runArgs(
|
|
172
|
-
const
|
|
238
|
+
function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunArgs {
|
|
239
|
+
const { hordes, count, flags } = parsed;
|
|
240
|
+
const args: HordeRunArgs = { kind: "run", hordes, count, prompt };
|
|
173
241
|
if (plan) args.plan = plan;
|
|
174
242
|
if (flags.dashboard) args.dashboard = flags.dashboard;
|
|
175
243
|
if (flags.noVerifier) args.noVerifier = true;
|
|
244
|
+
if (flags.untilStop) args.untilStop = true;
|
|
176
245
|
return args;
|
|
177
246
|
}
|
|
178
247
|
|
|
248
|
+
function parsePlan(rest: string, leading: LaunchFlags): HordeArgs {
|
|
249
|
+
const afterPlan = takeLaunchFlags(rest);
|
|
250
|
+
if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
|
|
251
|
+
const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
|
|
252
|
+
if ("error" in parsed) {
|
|
253
|
+
return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
|
|
254
|
+
}
|
|
255
|
+
const layers = parsed.rest
|
|
256
|
+
.split(/[;\n]/)
|
|
257
|
+
.map((layer) => layer.trim())
|
|
258
|
+
.filter(Boolean);
|
|
259
|
+
if (layers.length < 2) {
|
|
260
|
+
return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
|
|
261
|
+
}
|
|
262
|
+
if (layers.length < parsed.hordes) {
|
|
263
|
+
return { kind: "usage", error: `plan with ${parsed.hordes} hordes needs at least ${parsed.hordes} layers` };
|
|
264
|
+
}
|
|
265
|
+
return runArgs(parsed, layers.join("; "), layers);
|
|
266
|
+
}
|
|
267
|
+
|
|
179
268
|
export function parseHordeArgs(raw: string): HordeArgs {
|
|
180
269
|
const leading = takeLaunchFlags(raw);
|
|
181
270
|
if (leading.error) return { kind: "usage", error: leading.error };
|
|
182
271
|
const trimmed = leading.rest;
|
|
183
|
-
const hasFlags = leading.dashboard !== undefined || leading.noVerifier === true;
|
|
272
|
+
const hasFlags = leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true;
|
|
184
273
|
if (trimmed === "") {
|
|
185
274
|
return hasFlags ? { kind: "usage", error: "count is required" } : { kind: "usage" };
|
|
186
275
|
}
|
|
@@ -188,7 +277,7 @@ export function parseHordeArgs(raw: string): HordeArgs {
|
|
|
188
277
|
const space = trimmed.search(/\s/);
|
|
189
278
|
const head = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
|
|
190
279
|
const rest = space === -1 ? "" : trimmed.slice(space + 1);
|
|
191
|
-
if (hasFlags && head !== "plan" && !/^\d
|
|
280
|
+
if (hasFlags && head !== "plan" && !/^\d+(?:\/\d+)?$/.test(head)) {
|
|
192
281
|
return { kind: "usage", error: "launch flags only apply when starting a run" };
|
|
193
282
|
}
|
|
194
283
|
|
|
@@ -217,22 +306,8 @@ export function parseHordeArgs(raw: string): HordeArgs {
|
|
|
217
306
|
const target = parseTargets(rest);
|
|
218
307
|
return target ? { kind: "pause", target } : { kind: "usage", error: TARGET_ERROR };
|
|
219
308
|
}
|
|
220
|
-
case "plan":
|
|
221
|
-
|
|
222
|
-
if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
|
|
223
|
-
const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
|
|
224
|
-
if ("error" in parsed) {
|
|
225
|
-
return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
|
|
226
|
-
}
|
|
227
|
-
const layers = parsed.rest
|
|
228
|
-
.split(/[;\n]/)
|
|
229
|
-
.map((layer) => layer.trim())
|
|
230
|
-
.filter(Boolean);
|
|
231
|
-
if (layers.length < 2) {
|
|
232
|
-
return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
|
|
233
|
-
}
|
|
234
|
-
return runArgs(parsed.count, layers.join("; "), parsed.flags, layers);
|
|
235
|
-
}
|
|
309
|
+
case "plan":
|
|
310
|
+
return parsePlan(rest, leading);
|
|
236
311
|
case "resume": {
|
|
237
312
|
const target = parseTargets(rest);
|
|
238
313
|
return target ? { kind: "resume", target } : { kind: "usage", error: TARGET_ERROR };
|
|
@@ -242,5 +317,5 @@ export function parseHordeArgs(raw: string): HordeArgs {
|
|
|
242
317
|
const parsed = parseCountedRun(trimmed, leading);
|
|
243
318
|
if ("error" in parsed) return { kind: "usage", error: parsed.error };
|
|
244
319
|
if (parsed.rest === "") return { kind: "usage", error: "prompt is required" };
|
|
245
|
-
return runArgs(parsed
|
|
320
|
+
return runArgs(parsed, parsed.rest);
|
|
246
321
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/** What is running: the package version and, from a git checkout, the commit it is at. */
|
|
7
|
+
export interface BuildInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
version: string;
|
|
10
|
+
/** Short hash of HEAD, suffixed `-dirty` when the tree has uncommitted changes; absent outside a checkout. */
|
|
11
|
+
commit?: string | undefined;
|
|
12
|
+
/** The project's web repository, for linking the commit. */
|
|
13
|
+
repository?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const GIT_TIMEOUT_MS = 2_000;
|
|
17
|
+
|
|
18
|
+
/** The package directory: the parent of `src/`, which is also where `package.json` lives. */
|
|
19
|
+
export function packageRoot(): string {
|
|
20
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function repositoryUrl(raw: unknown): string | undefined {
|
|
24
|
+
const url = typeof raw === "string" ? raw : typeof raw === "object" && raw !== null ? (raw as { url?: unknown }).url : undefined;
|
|
25
|
+
if (typeof url !== "string") return undefined;
|
|
26
|
+
const web = url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
27
|
+
return web.startsWith("https://") ? web : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The commit of the checkout rooted at `root`, or undefined when `root` is not itself a checkout (no parent repo is consulted). */
|
|
31
|
+
function describeCommit(root: string): string | undefined {
|
|
32
|
+
if (!fs.existsSync(path.join(root, ".git"))) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync("git", ["describe", "--always", "--dirty", "--exclude=*", "--abbrev=7"], {
|
|
35
|
+
cwd: root,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
timeout: GIT_TIMEOUT_MS,
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
}).trim();
|
|
40
|
+
return /^[0-9a-f]{7,40}(?:-dirty)?$/.test(out) ? out : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads the package at `root`; throws when its package.json is missing or malformed. */
|
|
47
|
+
export function readBuildInfo(root: string): BuildInfo {
|
|
48
|
+
const file = path.join(root, "package.json");
|
|
49
|
+
let pkg: { name?: unknown; version?: unknown; repository?: unknown };
|
|
50
|
+
try {
|
|
51
|
+
pkg = JSON.parse(fs.readFileSync(file, "utf8")) as typeof pkg;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}; reinstall agent-horde`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof pkg.name !== "string" || typeof pkg.version !== "string") throw new Error(`${file} has no name or version; reinstall agent-horde`);
|
|
56
|
+
return { name: pkg.name, version: pkg.version, commit: describeCommit(root), repository: repositoryUrl(pkg.repository) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let cached: BuildInfo | undefined;
|
|
60
|
+
|
|
61
|
+
/** The running package's build, read once per process. */
|
|
62
|
+
export function buildInfo(): BuildInfo {
|
|
63
|
+
cached ??= readBuildInfo(packageRoot());
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { artifactAccepted, blockingMessages, collaborationState, openMessages } from "./ledger/collaboration.js";
|
|
3
|
+
import { artifactAccepted, artifactLimits, blockingMessages, collaborationState, openMessages, partialArtifactReviews } from "./ledger/collaboration.js";
|
|
4
4
|
import type { ArtifactRecord, CollaborationState, PeerMessage } from "./ledger/collaboration.js";
|
|
5
5
|
import { readEvents } from "./ledger/events.js";
|
|
6
6
|
import { findingReviewState, findingRevision, listFindingsForRun } from "./ledger/findings.js";
|
|
@@ -13,6 +13,8 @@ export interface CollaborationReport {
|
|
|
13
13
|
sequence: number;
|
|
14
14
|
state: CollaborationState;
|
|
15
15
|
issues: string[];
|
|
16
|
+
/** Limits the accepting reviews disclosed outside each accepted artifact's criteria. */
|
|
17
|
+
limits: string[];
|
|
16
18
|
acceptedArtifacts: string[];
|
|
17
19
|
acceptedDocuments: string[];
|
|
18
20
|
acceptedRunnableArtifacts: string[];
|
|
@@ -55,9 +57,14 @@ function artifactProblem(hordeDir: string, runId: string, state: CollaborationSt
|
|
|
55
57
|
// "needs a review" sent them after a review that already existed.
|
|
56
58
|
const blocker = blockingMessages(state).find((m) => m.target === artifact.id);
|
|
57
59
|
if (blocker) return `blocked by ${blocker.id} (${blocker.state} blocking ${blocker.kind} from ${blocker.sender})`;
|
|
58
|
-
|
|
60
|
+
if (artifactAccepted(state, artifact)) return undefined;
|
|
61
|
+
// A partial review is coverage debt, not an objection: the acceptance names what it left
|
|
62
|
+
// unchecked so a full review, or one covering that scope, can settle it.
|
|
63
|
+
const partial = partialArtifactReviews(state, artifact).map((r) => `${r.id} (${r.actor}) left unchecked: ${r.unchecked.trim()}`);
|
|
64
|
+
const need = artifact.kind === "document"
|
|
59
65
|
? "needs a full independent review against document criteria"
|
|
60
66
|
: "needs a full independent tested/reproduced review";
|
|
67
|
+
return partial.length ? `${need}; ${partial.join("; ")}` : need;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
export function loadCollaborationReport(hordeDir: string, runId: string): CollaborationReport {
|
|
@@ -65,10 +72,15 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
|
|
|
65
72
|
const state = collaborationState(hordeDir, runId);
|
|
66
73
|
const issues = blockingMessages(state).map((m) => `${m.id} ${m.target}: ${m.state} blocking ${m.kind}`);
|
|
67
74
|
const acceptedArtifacts: string[] = [];
|
|
75
|
+
const limits: string[] = [];
|
|
68
76
|
for (const artifact of state.artifacts) {
|
|
69
77
|
const problem = artifactProblem(hordeDir, runId, state, artifact);
|
|
70
|
-
if (problem)
|
|
71
|
-
|
|
78
|
+
if (problem) {
|
|
79
|
+
issues.push(`${artifact.id}@${artifact.hash}: ${problem}; exclude from recommended ${artifact.kind === "document" ? "document" : "runnable"} outputs`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
acceptedArtifacts.push(artifact.id);
|
|
83
|
+
limits.push(...artifactLimits(state, artifact).map((limit) => `${artifact.id}@${artifact.hash}: accepted with limits outside its criteria; ${limit}`));
|
|
72
84
|
}
|
|
73
85
|
for (const f of listFindingsForRun(hordeDir, runId)) {
|
|
74
86
|
if (f.supersededBy !== undefined) continue;
|
|
@@ -83,7 +95,7 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
|
|
|
83
95
|
const delays = messages.flatMap((m) => Object.values(m.deliveries).slice(0, 1).map((time) => Date.parse(time) - Date.parse(m.createdAt)));
|
|
84
96
|
const acceptedDocuments = state.artifacts.filter((a) => a.kind === "document" && acceptedArtifacts.includes(a.id)).map((a) => a.id);
|
|
85
97
|
const acceptedRunnableArtifacts = acceptedArtifacts.filter((id) => !acceptedDocuments.includes(id));
|
|
86
|
-
return { runId, sequence: state.seq, state, issues, acceptedArtifacts, acceptedDocuments, acceptedRunnableArtifacts, metrics: {
|
|
98
|
+
return { runId, sequence: state.seq, state, issues, limits, acceptedArtifacts, acceptedDocuments, acceptedRunnableArtifacts, metrics: {
|
|
87
99
|
events: events.length, rejected: events.filter((e) => e.outcome === "rejected").length,
|
|
88
100
|
unresolved: openMessages(state).length, blocking: blockingMessages(state).length,
|
|
89
101
|
resolvedChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "resolved").length,
|
|
@@ -116,9 +128,10 @@ function collaborationMarkdown(report: CollaborationReport): string {
|
|
|
116
128
|
return [
|
|
117
129
|
`# Collaboration ${report.runId}`, `\nEvent high-water mark: ${report.sequence}. Delivery records context insertion, not comprehension.`,
|
|
118
130
|
"\n## Acceptance issues", ...(report.issues.length ? report.issues.map((i) => `- ${i}`) : ["- No protocol-level issues recorded; task acceptance still requires coverage and review."]),
|
|
131
|
+
"\n## Accepted with limits", ...(report.limits.length ? report.limits.map((l) => `- ${l}`) : ["- none disclosed"]),
|
|
119
132
|
"\n## Questions, challenges and handoffs", ...state.messages.map(messageMarkdown),
|
|
120
|
-
"\n## Artifact revisions", ...state.artifacts.map((a) => `- ${a.id} ${a.path}@${a.hash} owner=${a.owner} kind=${a.kind ?? "runnable"} snapshot=${a.snapshot} accepted=${report.acceptedArtifacts.includes(a.id)}\n Criteria: ${a.criteria}\n Dependencies: ${JSON.stringify(a.dependencies)}`),
|
|
121
|
-
"\n## Reviews", ...state.reviews.map((r) => `- ${r.id} ${r.target}@${r.revision} @${r.actor} ${r.verdict} [${r.support}] evidence=${r.evidence.join(",")}\n Checked: ${r.checked}\n Unchecked: ${r.unchecked || "none declared"}\n ${r.text}`),
|
|
133
|
+
"\n## Artifact revisions", ...state.artifacts.map((a) => `- ${a.id} ${a.path}@${a.hash} owner=${a.owner} kind=${a.kind ?? "runnable"} snapshot=${a.snapshot} accepted=${report.acceptedArtifacts.includes(a.id)}\n Criteria: ${a.criteria}\n Dependencies: ${JSON.stringify(a.dependencies)}${a.changeNote ? `\n Change: ${a.changeNote}` : ""}`),
|
|
134
|
+
"\n## Reviews", ...state.reviews.map((r) => `- ${r.id} ${r.target}@${r.revision} @${r.actor} ${r.verdict} [${r.support}] evidence=${r.evidence.join(",")}${r.reattests ? ` reattests=${r.reattests}` : ""}\n Checked: ${r.checked}\n Unchecked: ${r.unchecked || "none declared"}\n Limits: ${r.limits || "none disclosed"}\n ${r.text}`),
|
|
122
135
|
"\n## Evidence", ...state.evidence.map((e) => `- ${e.id} ${e.target}@${e.revision} @${e.actor} ${e.relation}${e.path ? ` ${e.path}@${e.hash}` : ""}\n ${e.text}`),
|
|
123
136
|
"\n## Resources", ...state.resources.map((r) => `- ${r.target} ${r.state} generation=${r.generation} @${r.owner}/${r.attempt} ports=${r.ports.join(",")} lease=${new Date(r.leaseUntil).toISOString()}\n Health: ${r.health}`),
|
|
124
137
|
"\n## Metrics", ...Object.entries(report.metrics).map(([key, value]) => `- ${key}: ${value}`), "",
|
|
@@ -139,12 +152,13 @@ export function freezeAcceptance(hordeDir: string, runId: string, startIdeaId =
|
|
|
139
152
|
if (check.status === "disputed") issues.push(`${check.target}: unresolved challenge or refuting review`);
|
|
140
153
|
}
|
|
141
154
|
const runDir = path.join(hordeDir, "runs", runId);
|
|
142
|
-
const snapshot = { report, findings, findingReviews, ideas, issues };
|
|
155
|
+
const snapshot = { report, findings, findingReviews, ideas, issues, limits: report.limits };
|
|
143
156
|
const hash = hashText(JSON.stringify(snapshot));
|
|
144
157
|
const status = issues.length ? "incomplete" : "reviewed";
|
|
145
158
|
writeFileAtomic(path.join(runDir, "COLLABORATION.md"), collaborationMarkdown(report));
|
|
146
159
|
writeFileAtomic(path.join(runDir, "ACCEPTANCE.json"), JSON.stringify({ ...snapshot, hash, status }, null, 2));
|
|
147
|
-
|
|
160
|
+
const limitsSection = report.limits.length ? `\n\n## Accepted with limits\n\n${report.limits.map((l) => `- ${l}`).join("\n")}` : "";
|
|
161
|
+
writeFileAtomic(path.join(runDir, "ACCEPTANCE.md"), `# Acceptance ${runId}\n\nStatus: ${status}\nSnapshot: ${hash}\nEvent sequence: ${report.sequence}\n\n${issues.map((i) => `- ${i}`).join("\n")}${limitsSection}\n`);
|
|
148
162
|
const dir = path.join(runDir, "snapshot");
|
|
149
163
|
fs.mkdirSync(dir, { recursive: true });
|
|
150
164
|
for (const name of ["BOARD", "FINDINGS", "IDEAS", "FACTS", "CLAIMS", "RESOURCES", "CONCERNS"]) {
|