agent-horde 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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', or a comma/space separated list.",
18
- `count is a whole number from 1 to ${MAX_MEMBERS}`,
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
- /** Which members a control command applies to. */
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: "members"; names: string[] };
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 names: string[] = [];
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 match = /^(?:member-)?(\d+)$/i.exec(token);
63
- if (!match) return undefined;
64
- const index = Number(match[1]);
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 names.length > 0 ? { kind: "members", names } : { kind: "all" };
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 N";
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>]` and `--no-verifier` tokens, in any order. */
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
- /** Parse `<count> [flags] <tail>`. */
156
- function parseCountedRun(
157
- text: string,
158
- leading: LaunchFlags,
159
- ): { count: number; rest: string; flags: LaunchFlags } | { error: string } {
160
- const match = /^(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
161
- if (!match) return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
162
- const count = Number(match[1]);
163
- if (count < 1 || count > MAX_MEMBERS) {
164
- return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
165
- }
166
- const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[2] ?? ""));
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(count: number, prompt: string, flags: LaunchFlags, plan?: string[]): HordeRunArgs {
172
- const args: HordeRunArgs = { kind: "run", count, prompt };
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+$/.test(head)) {
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
- const afterPlan = takeLaunchFlags(rest);
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.count, parsed.rest, parsed.flags);
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
- return artifactAccepted(state, artifact) ? undefined : artifact.kind === "document"
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) issues.push(`${artifact.id}@${artifact.hash}: ${problem}; exclude from recommended ${artifact.kind === "document" ? "document" : "runnable"} outputs`);
71
- else acceptedArtifacts.push(artifact.id);
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
- 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")}\n`);
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"]) {