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/src/run/solved.ts CHANGED
@@ -4,7 +4,7 @@ import { loadCollaborationReport } from "../collaboration-report.js";
4
4
  import { openMessages } from "../ledger/collaboration.js";
5
5
  import { postBoard } from "../ledger/board.js";
6
6
  import { unsettledFactCitations } from "../ledger/facts.js";
7
- import { listFindingsForRun, staleAnswerCitations, unverifiedFindings } from "../ledger/findings.js";
7
+ import { staleAnswerFor, unverifiedFindings } from "../ledger/findings.js";
8
8
  import { listIdeas } from "../ledger/ideas.js";
9
9
  import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, reopenSolved, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
10
10
  import type { SolvedSignal } from "../ledger/run-signals.js";
@@ -80,14 +80,27 @@ function dropContested(run: HordeRun, proposal: SolvedSignal, contest: SolvedSig
80
80
  }
81
81
 
82
82
  /**
83
- * A proposal that cites a finding superseded or amended after it was made would ship the stale
84
- * reading as the answer. It is withdrawn for re-proposal rather than validated; the board names
85
- * the successor or the amendment.
83
+ * ANSWER.md keeps the withdrawn text for the record, but its status must stop naming the
84
+ * validation it held, or the run reads as validated after the proposal is gone.
85
+ */
86
+ function recordWithdrawn(run: HordeRun, proposal: SolvedSignal, reason: string): void {
87
+ void recordLifecycle(run, "solved-withdrawn", { member: proposal.member, reason });
88
+ try {
89
+ writeAnswer(run.runDir, run.id, proposal, `withdrawn: it ${reason}; no validation carries over and no proposal is pending`);
90
+ } catch (error) {
91
+ safeNotify(run, `horde ${run.id}: could not write ANSWER.md (${oneLine(String(error), 200)})`, "error");
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A proposal that cites a finding superseded or amended, or an artifact republished, after it was
97
+ * made would ship the stale reading as the answer. It is withdrawn for re-proposal rather than
98
+ * validated; the board names the successor, the amendment, or the republish.
86
99
  */
87
100
  function withdrawStale(run: HordeRun, proposal: SolvedSignal, stale: string[]): void {
88
101
  const reason = `cites ${stale.join(", ")}`;
89
102
  safeNotify(run, `horde ${run.id}: solved proposal withdrawn; it ${reason}`, "warning");
90
- void recordLifecycle(run, "solved-withdrawn", { member: proposal.member, reason });
103
+ recordWithdrawn(run, proposal, reason);
91
104
  void postBoard(
92
105
  run.hordeDir,
93
106
  "parent",
@@ -111,13 +124,13 @@ function reopenContested(run: HordeRun): void {
111
124
  // The proposal returns with the citations it pinned when it was made; what went stale or
112
125
  // unsettled meanwhile makes it a re-proposal for its author, not a reopening.
113
126
  const stale = [
114
- ...staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), contest.proposal.text, contest.proposal.cites),
127
+ ...staleAnswerFor(run.hordeDir, run.id, contest.proposal.text, contest.proposal.cites),
115
128
  ...unsettledFactCitations(run.hordeDir, contest.proposal.text).map((citation) => `provisional ${citation}`),
116
129
  ];
117
130
  run.solvedContest = undefined;
118
131
  if (stale.length) {
119
132
  const why = `cites ${stale.join(", ")}`;
120
- void recordLifecycle(run, "solved-withdrawn", { member: contest.proposal.member, reason: why });
133
+ recordWithdrawn(run, contest.proposal, why);
121
134
  void postBoard(run.hordeDir, "parent", `SOLVED proposal by ${contest.proposal.member} is not re-opened after ${reason}: it ${why}. ${contest.proposal.member}: propose again citing the current findings.`);
122
135
  return;
123
136
  }
@@ -262,7 +275,7 @@ export function checkSolved(run: HordeRun, ctx: ExtensionCommandContext): void {
262
275
  dropContested(run, solved, state.contest);
263
276
  return;
264
277
  }
265
- const stale = staleAnswerCitations(listFindingsForRun(run.hordeDir, run.id), solved.text, solved.cites);
278
+ const stale = staleAnswerFor(run.hordeDir, run.id, solved.text, solved.cites);
266
279
  if (stale.length && !run.solvedCommitted) {
267
280
  withdrawStale(run, solved, stale);
268
281
  return;
@@ -11,7 +11,7 @@ import { resetMemberState, spawnMember } from "../member.js";
11
11
  import type { MemberState } from "../member.js";
12
12
  import { hardStopMembers } from "./controls.js";
13
13
  import { recordLifecycle } from "./lifecycle.js";
14
- import type { HordeRun, HordeState } from "./types.js";
14
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
15
15
  import { refreshRun, safeNotify } from "./ui.js";
16
16
 
17
17
  /** A running member with no ledger write for this long is reset; 0 disables. */
@@ -37,6 +37,8 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
37
37
  /** What spawning and supervising a run's members needs beyond the run itself. */
38
38
  export interface RunLaunch {
39
39
  state: HordeState;
40
+ /** The swarm the run belongs to; a plain run is a swarm of one. */
41
+ swarm: Swarm;
40
42
  run: HordeRun;
41
43
  ctx: ExtensionCommandContext;
42
44
  /** Per-member role instruction, indexed by member index - 1. */
@@ -0,0 +1,121 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { errorMessage } from "../format.js";
4
+ import { SWARM_REPORT_TYPE, swarmHordeRows, swarmReportMarkdown, swarmReportText } from "./swarm-report.js";
5
+ import type { SwarmReportDetails } from "./swarm-report.js";
6
+ import { runSwarmDeliverable } from "./synthesis.js";
7
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
8
+ import { refreshSwarm, safeNotify, unmountWidget } from "./ui.js";
9
+
10
+ /** `SWARM.json`: the swarm's timeline for the dashboard once nothing is in memory. */
11
+ export interface SwarmRecord {
12
+ id: string;
13
+ prompt: string;
14
+ hordes: number;
15
+ count: number;
16
+ startedAt: number;
17
+ untilStop: boolean;
18
+ partition: string[];
19
+ runs: Array<{ label: string; runId: string; runDir: string; hordeDir: string; startedAt: number; endedAt?: number | undefined; outcome?: string | undefined }>;
20
+ /** The merge step: when the swarm deliverable helper ran and whether it wrote anything. */
21
+ merge?: { startedAt: number; endedAt?: number | undefined; written?: boolean | undefined } | undefined;
22
+ endedAt?: number | undefined;
23
+ }
24
+
25
+ export function swarmRecord(swarm: Swarm): SwarmRecord {
26
+ return {
27
+ id: swarm.id,
28
+ prompt: swarm.prompt,
29
+ hordes: swarm.hordes,
30
+ count: swarm.count,
31
+ startedAt: swarm.startedAt,
32
+ untilStop: swarm.untilStop === true,
33
+ partition: swarm.partition ?? [],
34
+ runs: swarm.runs.map((run) => ({
35
+ label: `h${run.horde}`,
36
+ runId: run.id,
37
+ runDir: run.runDir,
38
+ hordeDir: run.hordeDir,
39
+ startedAt: run.startedAt,
40
+ endedAt: run.finalized ? Math.max(run.startedAt, ...run.members.map((m) => m.endedAt ?? 0)) : undefined,
41
+ outcome: run.outcome,
42
+ })),
43
+ };
44
+ }
45
+
46
+ /** Write a swarm file, reporting (not throwing) a failure so finalize keeps going. */
47
+ function writeSwarmFile(swarm: Swarm, name: string, content: string): void {
48
+ if (!swarm.dir) return;
49
+ try {
50
+ fs.writeFileSync(path.join(swarm.dir, name), content, "utf-8");
51
+ } catch (error) {
52
+ safeNotify(swarm, `swarm ${swarm.id}: could not write ${name} (${errorMessage(error)})`, "error");
53
+ }
54
+ }
55
+
56
+ export function writeSwarmRecord(swarm: Swarm, extra: Partial<SwarmRecord> = {}): void {
57
+ writeSwarmFile(swarm, "SWARM.json", JSON.stringify({ ...swarmRecord(swarm), ...extra }, null, 2));
58
+ }
59
+
60
+ /**
61
+ * A horde's members exited and its REPORT.md is written. Once every horde is there the swarm
62
+ * slot frees, so a new launch can start while the (possibly slow) synthesis steps run.
63
+ */
64
+ export function noteRunReported(state: HordeState, swarm: Swarm, run: HordeRun): void {
65
+ run.reported = true;
66
+ if (!swarm.runs.every((other) => other.reported === true)) return;
67
+ if (state.active === swarm) state.active = undefined;
68
+ state.finalizing = swarm;
69
+ }
70
+
71
+ /** The merged deliverable, REPORT.md, and the injected message of a swarm of several hordes. */
72
+ async function mergeSwarm(state: HordeState, swarm: Swarm): Promise<void> {
73
+ swarm.finalizing = ["merge"];
74
+ refreshSwarm(swarm);
75
+ const merge: NonNullable<SwarmRecord["merge"]> = { startedAt: Date.now() };
76
+ writeSwarmRecord(swarm, { merge });
77
+ swarm.deliverable = process.env.PI_HORDE_DELIVERABLE === "off" ? undefined : await runSwarmDeliverable(state, swarm);
78
+ if (swarm.deliverable) writeSwarmFile(swarm, "DELIVERABLE.md", swarm.deliverable);
79
+ const now = Date.now();
80
+ merge.endedAt = now;
81
+ merge.written = Boolean(swarm.deliverable);
82
+ writeSwarmFile(swarm, "REPORT.md", swarmReportMarkdown(swarm, now));
83
+ writeSwarmRecord(swarm, { merge, endedAt: now });
84
+ if (state.disposed) return;
85
+ const details: SwarmReportDetails = {
86
+ swarmId: swarm.id,
87
+ elapsedMs: now - swarm.startedAt,
88
+ hordes: swarmHordeRows(swarm, now),
89
+ deliverable: swarm.deliverable,
90
+ };
91
+ const content = [swarmReportText(swarm, now), ...(swarm.deliverable ? ["## Deliverable", "", swarm.deliverable] : [])].join("\n\n");
92
+ // A hard stop of every horde keeps the parent idle, as it does for one horde.
93
+ const triggerTurn = swarm.runs.some((run) => run.cleanStopNames.length > 0 || !run.stopped);
94
+ state.pi.sendMessage(
95
+ { customType: SWARM_REPORT_TYPE, content, display: true, details },
96
+ { deliverAs: "followUp", triggerTurn },
97
+ );
98
+ safeNotify(swarm, `swarm ${swarm.id} finished`, "info");
99
+ }
100
+
101
+ /**
102
+ * A horde's finalize completed, synthesis included. The swarm step runs once every horde is
103
+ * done: a swarm of one has nothing to merge and only releases the widget.
104
+ */
105
+ export async function noteRunFinalized(state: HordeState, swarm: Swarm, run: HordeRun): Promise<void> {
106
+ run.finalized = true;
107
+ if (!swarm.runs.every((other) => other.finalized === true)) {
108
+ refreshSwarm(swarm);
109
+ return;
110
+ }
111
+ try {
112
+ if (swarm.dir && !state.disposed) await mergeSwarm(state, swarm);
113
+ } catch (error) {
114
+ safeNotify(swarm, `swarm ${swarm.id} failed to finalize: ${errorMessage(error)}`, "error");
115
+ } finally {
116
+ swarm.finalizing = undefined;
117
+ if (state.active === swarm) state.active = undefined;
118
+ if (state.finalizing === swarm) state.finalizing = undefined;
119
+ unmountWidget(swarm);
120
+ }
121
+ }
@@ -0,0 +1,152 @@
1
+ import * as path from "node:path";
2
+ import { formatDateTime, formatElapsed, formatTokens, oneLine } from "../format.js";
3
+ import { listFindingsForRun } from "../ledger/findings.js";
4
+ import { statusCounts, totalUsage } from "../prompt.js";
5
+ import { hordeLabel } from "./types.js";
6
+ import type { HordeRun, Swarm } from "./types.js";
7
+
8
+ /** One horde's totals for the swarm report and the injected message. */
9
+ export interface SwarmHordeRow {
10
+ label: string;
11
+ runId: string;
12
+ runDir: string;
13
+ outcome: string;
14
+ members: number;
15
+ done: number;
16
+ failed: number;
17
+ stopped: number;
18
+ turns: number;
19
+ tokens: number;
20
+ cost: number;
21
+ findings: number;
22
+ elapsedMs: number;
23
+ deliverable: boolean;
24
+ postmortem: boolean;
25
+ }
26
+
27
+ /** What the swarm report message carries for its renderer. */
28
+ export interface SwarmReportDetails {
29
+ swarmId: string;
30
+ elapsedMs: number;
31
+ hordes: SwarmHordeRow[];
32
+ deliverable?: string | undefined;
33
+ }
34
+
35
+ export const SWARM_REPORT_TYPE = "agent-horde-swarm-report";
36
+
37
+ function findingCount(run: HordeRun): number {
38
+ try {
39
+ return listFindingsForRun(run.hordeDir, run.id).length;
40
+ } catch {
41
+ return 0;
42
+ }
43
+ }
44
+
45
+ function hordeRow(run: HordeRun, now: number): SwarmHordeRow {
46
+ const counts = statusCounts(run.members);
47
+ const usage = totalUsage(run.members);
48
+ const ended = Math.max(...run.members.map((member) => member.endedAt ?? now), run.startedAt);
49
+ return {
50
+ label: hordeLabel(run.horde),
51
+ runId: run.id,
52
+ runDir: run.runDir,
53
+ outcome: run.outcome ?? (run.finalized ? "finished" : "running"),
54
+ members: run.members.length,
55
+ done: counts.done,
56
+ failed: counts.failed,
57
+ stopped: counts.stopped,
58
+ turns: usage.turns,
59
+ tokens: usage.input + usage.output,
60
+ cost: usage.cost,
61
+ findings: findingCount(run),
62
+ elapsedMs: ended - run.startedAt,
63
+ deliverable: Boolean(run.deliverable),
64
+ postmortem: Boolean(run.postmortem),
65
+ };
66
+ }
67
+
68
+ export function swarmHordeRows(swarm: Swarm, now: number = Date.now()): SwarmHordeRow[] {
69
+ return swarm.runs.map((run) => hordeRow(run, now));
70
+ }
71
+
72
+ function sum(rows: SwarmHordeRow[], pick: (row: SwarmHordeRow) => number): number {
73
+ return rows.reduce((total, row) => total + pick(row), 0);
74
+ }
75
+
76
+ function hordeTable(rows: SwarmHordeRow[]): string[] {
77
+ return [
78
+ "| horde | run | outcome | members | done | failed | stopped | turns | tokens | cost | findings | elapsed |",
79
+ "|---|---|---|---|---|---|---|---|---|---|---|---|",
80
+ ...rows.map((row) =>
81
+ `| ${row.label} | ${row.runId} | ${row.outcome} | ${row.members} | ${row.done} | ${row.failed} | ` +
82
+ `${row.stopped} | ${row.turns} | ${formatTokens(row.tokens)} | $${row.cost.toFixed(4)} | ${row.findings} | ${formatElapsed(row.elapsedMs)} |`),
83
+ `| total | | | ${sum(rows, (r) => r.members)} | ${sum(rows, (r) => r.done)} | ${sum(rows, (r) => r.failed)} | ` +
84
+ `${sum(rows, (r) => r.stopped)} | ${sum(rows, (r) => r.turns)} | ${formatTokens(sum(rows, (r) => r.tokens))} | ` +
85
+ `$${sum(rows, (r) => r.cost).toFixed(4)} | ${sum(rows, (r) => r.findings)} | |`,
86
+ ];
87
+ }
88
+
89
+ /** REPORT.md of a swarm: the partition, per-horde totals, where each horde's files are, the merged deliverable. */
90
+ export function swarmReportMarkdown(swarm: Swarm, now: number = Date.now()): string {
91
+ const rows = swarmHordeRows(swarm, now);
92
+ return [
93
+ `# Swarm ${swarm.id}`,
94
+ "",
95
+ `- started: ${formatDateTime(new Date(swarm.startedAt))}`,
96
+ `- elapsed: ${formatElapsed(now - swarm.startedAt)}`,
97
+ `- hordes: ${swarm.hordes} x ${swarm.count} members`,
98
+ `- until stop: ${swarm.untilStop ? "yes" : "no"}`,
99
+ `- outcomes: ${rows.map((row) => `${row.label} ${row.outcome}`).join(", ")}`,
100
+ `- merged deliverable: ${swarm.deliverable ? "DELIVERABLE.md" : "not written"}`,
101
+ "",
102
+ "## Prompt",
103
+ "",
104
+ swarm.prompt,
105
+ "",
106
+ "## Partition",
107
+ "",
108
+ ...(swarm.partition ?? []).map((slice, index) => `- ${hordeLabel(index + 1)}: ${slice}`),
109
+ "",
110
+ "## Hordes",
111
+ "",
112
+ ...hordeTable(rows),
113
+ "",
114
+ ...rows.flatMap((row) => [
115
+ `### ${row.label}: ${row.runId}`,
116
+ "",
117
+ `- ${path.join(row.runDir, "REPORT.md")}`,
118
+ ...(row.deliverable ? [`- ${path.join(row.runDir, "DELIVERABLE.md")}`] : []),
119
+ ...(row.postmortem ? [`- ${path.join(row.runDir, "POSTMORTEM.md")}`] : []),
120
+ "",
121
+ ]),
122
+ ...(swarm.deliverable ? ["## Deliverable", "", swarm.deliverable, ""] : []),
123
+ ].join("\n");
124
+ }
125
+
126
+ /** The message injected into the parent conversation when a swarm finishes. */
127
+ export function swarmReportText(swarm: Swarm, now: number = Date.now()): string {
128
+ const rows = swarmHordeRows(swarm, now);
129
+ const dir = swarm.dir ?? "";
130
+ return [
131
+ `Swarm ${swarm.id} finished after ${formatElapsed(now - swarm.startedAt)}: ${swarm.hordes} hordes of ` +
132
+ `${swarm.count} members on one task, partitioned as ${rows.map((row) => `${row.label} (${row.outcome})`).join(", ")}.`,
133
+ `${sum(rows, (r) => r.turns)} turns, ${formatTokens(sum(rows, (r) => r.tokens))} tokens, ` +
134
+ `$${sum(rows, (r) => r.cost).toFixed(2)}, ${sum(rows, (r) => r.findings)} findings across the hordes.`,
135
+ "",
136
+ "Partition:",
137
+ ...(swarm.partition ?? []).map((slice, index) => `- ${hordeLabel(index + 1)}: ${oneLine(slice, 200)}`),
138
+ "",
139
+ "Files:",
140
+ `- ${path.join(dir, "REPORT.md")} per-horde totals and where each horde's files are`,
141
+ ...(swarm.deliverable ? [`- ${path.join(dir, "DELIVERABLE.md")} merged deliverable, each claim attributed to its horde`] : []),
142
+ `- ${path.join(dir, "SWARM.md")} the prompt and the partition`,
143
+ ...rows.map((row) => `- ${row.runDir}/ ${row.label}: REPORT.md, DELIVERABLE.md, POSTMORTEM.md, SUMMARY.md`),
144
+ "",
145
+ ...(swarm.deliverable
146
+ ? [
147
+ "The merged deliverable is included below. No horde reviewed another's work, so verify claims",
148
+ "against the owning horde's FINDINGS.md and ACCEPTANCE.md before presenting them to the user.",
149
+ ]
150
+ : ["No merged deliverable was written; read each horde's REPORT.md and DELIVERABLE.md."]),
151
+ ].join("\n");
152
+ }
@@ -0,0 +1,156 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { formatDateTime, formatRunId, taskHash } from "../format.js";
4
+ import { hordePaths, initHordeDirAt } from "../ledger/files.js";
5
+ import type { SiblingHorde } from "../member.js";
6
+ import { hordeLabel } from "./types.js";
7
+ import type { Swarm } from "./types.js";
8
+
9
+ /** Where a swarm's ids and directories landed. */
10
+ export interface SwarmLayout {
11
+ id: string;
12
+ /** `.horde/swarms/<id>`; undefined for a swarm of one. */
13
+ dir: string | undefined;
14
+ /** One complete horde directory per horde, `h1` first. */
15
+ hordeDirs: string[];
16
+ /** Run ids, `h1` first: the swarm id alone for a swarm of one, else `<id>-h<N>`. */
17
+ runIds: string[];
18
+ /** `<hordeDir>/runs/<runId>`, created and empty but for `sessions/`. */
19
+ runDirs: string[];
20
+ }
21
+
22
+ /** Make `<parent>/<base>` or the first free `<base>-<n>`, exclusively, and return its name. */
23
+ function allocateUnique(parent: string, base: string): string {
24
+ for (let attempt = 0; ; attempt++) {
25
+ const name = attempt === 0 ? base : `${base}-${attempt}`;
26
+ try {
27
+ fs.mkdirSync(path.join(parent, name));
28
+ return name;
29
+ } catch (error) {
30
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
31
+ }
32
+ }
33
+ }
34
+
35
+ function makeRunDir(hordeDir: string, runId: string): string {
36
+ const runDir = path.join(hordePaths(hordeDir).runs, runId);
37
+ fs.mkdirSync(path.join(runDir, "sessions"), { recursive: true });
38
+ return runDir;
39
+ }
40
+
41
+ /** The swarm directories hold whole horde directories, so they are run output, not ledgers. */
42
+ function ignoreSwarms(hordeDir: string): void {
43
+ const gitignore = hordePaths(hordeDir).gitignore;
44
+ const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf-8") : "";
45
+ if (existing.split("\n").some((line) => line.trim() === "swarms/")) return;
46
+ fs.writeFileSync(gitignore, `${existing}${existing === "" || existing.endsWith("\n") ? "" : "\n"}swarms/\n`, "utf-8");
47
+ }
48
+
49
+ /**
50
+ * Allocate the swarm's id and directories. A swarm of one keeps the plain time-based run id
51
+ * under `.horde/runs/`, so single runs share the project ledgers as before; a larger swarm gets
52
+ * `.horde/swarms/<id>/h<N>/`, each a complete horde directory with its own ledgers and lock.
53
+ */
54
+ export function allocateSwarm(hordeDir: string, hordes: number, startedAt: number): SwarmLayout {
55
+ const base = formatRunId(new Date(startedAt));
56
+ if (hordes === 1) {
57
+ const id = allocateUnique(hordePaths(hordeDir).runs, base);
58
+ const runDir = path.join(hordePaths(hordeDir).runs, id);
59
+ fs.mkdirSync(path.join(runDir, "sessions"), { recursive: true });
60
+ return { id, dir: undefined, hordeDirs: [hordeDir], runIds: [id], runDirs: [runDir] };
61
+ }
62
+ const swarms = path.join(hordeDir, "swarms");
63
+ fs.mkdirSync(swarms, { recursive: true });
64
+ ignoreSwarms(hordeDir);
65
+ const id = allocateUnique(swarms, base);
66
+ const dir = path.join(swarms, id);
67
+ const layout: SwarmLayout = { id, dir, hordeDirs: [], runIds: [], runDirs: [] };
68
+ for (let horde = 1; horde <= hordes; horde++) {
69
+ const hordeDirN = initHordeDirAt(path.join(dir, hordeLabel(horde)));
70
+ const runId = `${id}-${hordeLabel(horde)}`;
71
+ layout.hordeDirs.push(hordeDirN);
72
+ layout.runIds.push(runId);
73
+ layout.runDirs.push(makeRunDir(hordeDirN, runId));
74
+ }
75
+ return layout;
76
+ }
77
+
78
+ /** Deal `plan` layers to `hordes` hordes round-robin: layer 1 to h1, layer 2 to h2, and so on. */
79
+ export function dealLayers(plan: string[], hordes: number): string[][] {
80
+ const dealt: string[][] = Array.from({ length: hordes }, () => []);
81
+ plan.forEach((layer, index) => dealt[index % hordes]!.push(layer));
82
+ return dealt;
83
+ }
84
+
85
+ /** The horde directories a member may read besides its own, with each horde's workstream. */
86
+ export function siblingHordes(layout: SwarmLayout, partition: string[] | undefined, horde: number): SiblingHorde[] {
87
+ return layout.hordeDirs
88
+ .map((hordeDir, index) => ({ label: hordeLabel(index + 1), hordeDir, slice: partition?.[index] }))
89
+ .filter((_sibling, index) => index + 1 !== horde);
90
+ }
91
+
92
+ /** SWARM.md: the prompt, the partition, and where each horde works. */
93
+ export function swarmMarkdown(swarm: Swarm): string {
94
+ const partition = swarm.partition ?? [];
95
+ return [
96
+ `# Swarm ${swarm.id}`,
97
+ "",
98
+ `Started: ${formatDateTime(new Date(swarm.startedAt))}`,
99
+ `Hordes: ${swarm.hordes} x ${swarm.count} members (${hordeLabel(1)} .. ${hordeLabel(swarm.hordes)})`,
100
+ `Model: ${swarm.runs[0]?.model ?? "parent default"}`,
101
+ `Until stop: ${swarm.untilStop ? "yes (every horde continues until stop-clean or stop-hard)" : "no"}`,
102
+ `Partition: ${swarm.plan ? "plan layers dealt round-robin" : "planner helper"}`,
103
+ `Task-SHA256: ${taskHash(swarm.prompt)}`,
104
+ "",
105
+ "## Prompt",
106
+ "",
107
+ swarm.prompt,
108
+ "",
109
+ "## Partition",
110
+ "",
111
+ ...partition.map((slice, index) => `- ${hordeLabel(index + 1)}: ${slice}`),
112
+ "",
113
+ "## Hordes",
114
+ "",
115
+ ...swarm.runs.map((run) => `- ${hordeLabel(run.horde)}: ${run.runDir}`),
116
+ "",
117
+ ].join("\n");
118
+ }
119
+
120
+ /** A planner slice as the members read it: the workstream, then its acceptance criterion. */
121
+ export function sliceText(workstream: string, acceptance: string | undefined): string {
122
+ const trimmed = workstream.trim();
123
+ const criterion = acceptance?.trim();
124
+ return criterion ? `${trimmed} Acceptance: ${criterion}` : trimmed;
125
+ }
126
+
127
+ /**
128
+ * The planner's output as H distinct workstreams, or why it is unusable. The helper is told to
129
+ * print only a JSON array; anything around it is tolerated, a wrong count or a repeat is not.
130
+ */
131
+ export function parsePlannerOutput(output: string, hordes: number): { slices: string[] } | { error: string } {
132
+ const start = output.indexOf("[");
133
+ const end = output.lastIndexOf("]");
134
+ if (start === -1 || end <= start) return { error: "the planner printed no JSON array" };
135
+ let items: unknown;
136
+ try {
137
+ items = JSON.parse(output.slice(start, end + 1));
138
+ } catch (error) {
139
+ return { error: `the planner's JSON did not parse (${error instanceof Error ? error.message : String(error)})` };
140
+ }
141
+ if (!Array.isArray(items)) return { error: "the planner printed something other than an array" };
142
+ if (items.length !== hordes) return { error: `the planner produced ${items.length} workstream(s), not ${hordes}` };
143
+ const slices: string[] = [];
144
+ const seen = new Set<string>();
145
+ for (const item of items) {
146
+ const record = (typeof item === "object" && item !== null ? item : {}) as Record<string, unknown>;
147
+ const workstream = typeof record.workstream === "string" ? record.workstream : typeof item === "string" ? item : "";
148
+ const acceptance = typeof record.acceptance === "string" ? record.acceptance : undefined;
149
+ const key = workstream.trim().toLowerCase().replace(/\s+/g, " ");
150
+ if (key === "") return { error: "the planner produced an empty workstream" };
151
+ if (seen.has(key)) return { error: `the planner repeated a workstream: ${workstream.trim()}` };
152
+ seen.add(key);
153
+ slices.push(sliceText(workstream, acceptance));
154
+ }
155
+ return { slices };
156
+ }