agent-horde 0.1.6 → 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.
@@ -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
+ }
@@ -2,11 +2,16 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { oneLine } from "../format.js";
4
4
  import { childEnv, execPi, getPiInvocation } from "../member.js";
5
- import type { HordeRun, HordeState } from "./types.js";
5
+ import { parsePlannerOutput } from "./swarm.js";
6
+ import { hordeLabel } from "./types.js";
7
+ import type { HordeRun, HordeState, Swarm } from "./types.js";
6
8
 
7
9
  /** How long the post-mortem agent may run. */
8
10
  const POSTMORTEM_TIMEOUT_MS = Number(process.env.PI_HORDE_POSTMORTEM_MS) || 300_000;
9
11
 
12
+ /** How long the planner that partitions a task between hordes may run. */
13
+ const PLANNER_TIMEOUT_MS = Number(process.env.PI_HORDE_PLANNER_MS) || 300_000;
14
+
10
15
  const POSTMORTEM_SYSTEM_PROMPT =
11
16
  "You are a post-mortem reviewer for a multi-agent horde run. You have read-only tools. " +
12
17
  "Critique the run and the orchestrating extension, not just the underlying project. Be " +
@@ -17,43 +22,63 @@ const DELIVERABLE_SYSTEM_PROMPT =
17
22
  "You are the synthesis member of a horde. You have read-only tools and must produce one " +
18
23
  "deliverable that answers the task the horde was given, not a summary of the process.";
19
24
 
20
- type HelperLabel = "post-mortem" | "deliverable";
25
+ const SWARM_DELIVERABLE_SYSTEM_PROMPT =
26
+ "You are the synthesis member of a swarm of hordes. You have read-only tools and must merge the " +
27
+ "hordes' deliverables into one deliverable that answers the task, keeping every claim attributed " +
28
+ "to the horde that made it and to the acceptance status that horde recorded.";
29
+
30
+ const PLANNER_SYSTEM_PROMPT =
31
+ "You are the planner for a swarm of agent hordes. You have read-only tools. You partition one task " +
32
+ "into disjoint workstreams that independent teams can work in parallel without overlapping. You " +
33
+ "output only the JSON you are asked for.";
34
+
35
+ type HelperLabel = "post-mortem" | "deliverable" | "swarm deliverable" | "planner";
21
36
 
22
37
  /** Run a one-shot pi helper and return its output, or a one-line failure text. */
23
38
  async function runHelper(
24
39
  label: HelperLabel,
25
40
  invocation: { command: string; args: string[] },
26
- cwd: string,
27
- signal: AbortSignal,
28
- ): Promise<string> {
41
+ options: { cwd: string; signal: AbortSignal; timeoutMs?: number | undefined },
42
+ ): Promise<{ text: string; failed: boolean }> {
43
+ const timeoutMs = options.timeoutMs ?? POSTMORTEM_TIMEOUT_MS;
29
44
  const result = await execPi(invocation, {
30
- cwd,
45
+ cwd: options.cwd,
31
46
  env: childEnv(),
32
- timeoutMs: POSTMORTEM_TIMEOUT_MS,
33
- signal,
47
+ timeoutMs,
48
+ signal: options.signal,
34
49
  });
35
50
  const text = result.stdout.trim();
36
51
  if (result.timedOut) {
37
- return `${label} agent failed: timed out after ${Math.round(POSTMORTEM_TIMEOUT_MS / 1000)}s`;
52
+ return { text: `${label} agent failed: timed out after ${Math.round(timeoutMs / 1000)}s`, failed: true };
38
53
  }
39
54
  if (result.code !== 0 || text === "") {
40
55
  const detail = oneLine(result.stderr.trim() || "no output", 200);
41
- return `${label} agent failed (code ${result.code ?? "signal"}): ${detail}`;
56
+ return { text: `${label} agent failed (code ${result.code ?? "signal"}): ${detail}`, failed: true };
42
57
  }
43
- return text;
58
+ return { text, failed: false };
59
+ }
60
+
61
+ /** Read-only pi invocation with its own session directory. */
62
+ function readOnlyInvocation(
63
+ sessions: string,
64
+ model: string | undefined,
65
+ systemPrompt: string,
66
+ task: string,
67
+ ): { command: string; args: string[] } {
68
+ fs.mkdirSync(sessions, { recursive: true });
69
+ const args = ["-p", "--tools", "read,grep,find,ls", "--session-dir", sessions];
70
+ if (model) args.push("--model", model);
71
+ args.push("--approve", "--append-system-prompt", systemPrompt, "--", task);
72
+ return getPiInvocation(args);
44
73
  }
45
74
 
46
- /** Read-only pi invocation for a helper, with its own session directory under the run. */
75
+ /** A run helper's invocation: ledger paths are swapped for the frozen snapshot when one exists. */
47
76
  function helperInvocation(
48
77
  run: HordeRun,
49
78
  sessionDir: string,
50
79
  systemPrompt: string,
51
80
  task: string,
52
81
  ): { command: string; args: string[] } {
53
- const sessions = path.join(run.runDir, sessionDir);
54
- fs.mkdirSync(sessions, { recursive: true });
55
- const args = ["-p", "--tools", "read,grep,find,ls", "--session-dir", sessions];
56
- if (run.model) args.push("--model", run.model);
57
82
  for (const name of ["BOARD", "FINDINGS", "IDEAS", "FACTS", "CLAIMS", "RESOURCES", "CONCERNS"]) {
58
83
  const frozen = path.join(run.runDir, "snapshot", `${name}.md`);
59
84
  if (fs.existsSync(frozen)) task = task.split(path.join(run.hordeDir, `${name}.md`)).join(frozen);
@@ -62,8 +87,7 @@ function helperInvocation(
62
87
  "Use the frozen acceptance snapshot. Exclude unreviewed, disputed or stale artifacts from recommended runnable outputs. " +
63
88
  "acceptedDocuments establishes document-criteria review only; recommend runnable outputs only from acceptedRunnableArtifacts. Preserve recorded peer confirmation even if acceptance remains incomplete. " +
64
89
  "Disclose partial checks and unresolved coverage. Legacy verified labels alone do not establish a full reproduction.";
65
- args.push("--approve", "--append-system-prompt", systemPrompt, "--", task);
66
- return getPiInvocation(args);
90
+ return readOnlyInvocation(path.join(run.runDir, sessionDir), run.model, systemPrompt, task);
67
91
  }
68
92
 
69
93
  function postmortemTask(run: HordeRun): string {
@@ -125,6 +149,7 @@ function deliverableTask(run: HordeRun): string {
125
149
  ``,
126
150
  run.prompt,
127
151
  ``,
152
+ ...(run.slice ? [`This horde owned one workstream of that task: ${run.slice}`, `Deliver that workstream's part of the answer.`, ``] : []),
128
153
  `Read these with the read tool:`,
129
154
  `- ${path.join(run.runDir, "REPORT.md")}`,
130
155
  `- ${path.join(run.runDir, "SUMMARY.md")}`,
@@ -170,7 +195,7 @@ export async function runPostmortem(state: HordeState, run: HordeRun): Promise<s
170
195
  const controller = new AbortController();
171
196
  run.postmortemAbort = controller;
172
197
  try {
173
- return await runHelper("post-mortem", invocation, run.cwd, controller.signal);
198
+ return (await runHelper("post-mortem", invocation, { cwd: run.cwd, signal: controller.signal })).text;
174
199
  } finally {
175
200
  run.postmortemAbort = undefined;
176
201
  }
@@ -188,8 +213,104 @@ export async function runDeliverable(state: HordeState, run: HordeRun): Promise<
188
213
  const controller = new AbortController();
189
214
  run.deliverableAbort = controller;
190
215
  try {
191
- return await runHelper("deliverable", invocation, run.cwd, controller.signal);
216
+ return (await runHelper("deliverable", invocation, { cwd: run.cwd, signal: controller.signal })).text;
192
217
  } finally {
193
218
  run.deliverableAbort = undefined;
194
219
  }
195
220
  }
221
+
222
+ function swarmDeliverableTask(swarm: Swarm): string {
223
+ const dir = swarm.dir ?? "";
224
+ return [
225
+ `Produce the single merged deliverable for swarm ${swarm.id}: ${swarm.hordes} hordes worked disjoint`,
226
+ `workstreams of one task in parallel, and each wrote its own deliverable.`,
227
+ ``,
228
+ `The task the swarm was given:`,
229
+ ``,
230
+ swarm.prompt,
231
+ ``,
232
+ `The partition:`,
233
+ ...(swarm.partition ?? []).map((slice, index) => `- ${hordeLabel(index + 1)}: ${slice}`),
234
+ ``,
235
+ `Read these with the read tool:`,
236
+ `- ${path.join(dir, "SWARM.md")}`,
237
+ ...swarm.runs.flatMap((run) => [
238
+ `- ${path.join(run.runDir, "DELIVERABLE.md")} (${hordeLabel(run.horde)}; absent when that horde was hard-stopped)`,
239
+ `- ${path.join(run.runDir, "REPORT.md")} (${hordeLabel(run.horde)})`,
240
+ `- ${path.join(run.runDir, "ACCEPTANCE.md")} (${hordeLabel(run.horde)}; when present)`,
241
+ `- ${path.join(run.runDir, "ANSWER.md")} (${hordeLabel(run.horde)}; when a solve was proposed)`,
242
+ ]),
243
+ ``,
244
+ `Merge the hordes' deliverables into one artifact that answers the whole task in the form the task`,
245
+ `implies. Keep every claim attributed to its horde (${swarm.runs.map((run) => hordeLabel(run.horde)).join(", ")})`,
246
+ `and keep the acceptance label its horde recorded; do not upgrade a claim's support by combining`,
247
+ `hordes, since no horde reviewed another's work. Where hordes overlap or contradict each other, say`,
248
+ `so and keep both attributions. Name any workstream whose horde produced no deliverable. Do not`,
249
+ `describe the process. Output only markdown.`,
250
+ ].join("\n");
251
+ }
252
+
253
+ /** Spawn the synthesis member that merges the hordes' deliverables into the swarm's. */
254
+ export async function runSwarmDeliverable(state: HordeState, swarm: Swarm): Promise<string | undefined> {
255
+ if (state.disposed || !swarm.dir) return undefined;
256
+ const invocation = readOnlyInvocation(
257
+ path.join(swarm.dir, "deliverable-sessions"),
258
+ swarm.runs[0]?.model,
259
+ SWARM_DELIVERABLE_SYSTEM_PROMPT,
260
+ swarmDeliverableTask(swarm),
261
+ );
262
+ const controller = new AbortController();
263
+ swarm.deliverableAbort = controller;
264
+ try {
265
+ return (await runHelper("swarm deliverable", invocation, { cwd: swarm.cwd, signal: controller.signal })).text;
266
+ } finally {
267
+ swarm.deliverableAbort = undefined;
268
+ }
269
+ }
270
+
271
+ function plannerTask(prompt: string, hordes: number): string {
272
+ return [
273
+ `Partition the task below into exactly ${hordes} disjoint workstreams for ${hordes} independent teams`,
274
+ `that will work in parallel in this directory without talking to each other. Each workstream must`,
275
+ `be self-contained, must not overlap another, and should take about the same effort; together they`,
276
+ `must cover the whole task. Give each workstream one acceptance criterion a team can check on its`,
277
+ `own. Read the working directory as needed to split the task along real seams (modules, inputs,`,
278
+ `chapters, question groups); do not modify anything.`,
279
+ ``,
280
+ `Output ONLY a JSON array of exactly ${hordes} objects, nothing before or after it:`,
281
+ `[{"workstream": "<what this team does>", "acceptance": "<how the team knows it is done>"}, ...]`,
282
+ ``,
283
+ `Task:`,
284
+ ``,
285
+ prompt,
286
+ ].join("\n");
287
+ }
288
+
289
+ /**
290
+ * Partition a plain prompt between the hordes with a read-only planner. Returns the H slices,
291
+ * or the reason the launch must stop: a planner that cannot produce H distinct workstreams
292
+ * would have every horde doing the same work.
293
+ */
294
+ export async function runPlanner(
295
+ state: HordeState,
296
+ input: { dir: string; cwd: string; model: string | undefined; prompt: string; hordes: number },
297
+ ): Promise<{ slices: string[] } | { error: string }> {
298
+ if (state.disposed) return { error: "session shut down" };
299
+ const invocation = readOnlyInvocation(
300
+ path.join(input.dir, "planner-sessions"),
301
+ input.model,
302
+ PLANNER_SYSTEM_PROMPT,
303
+ plannerTask(input.prompt, input.hordes),
304
+ );
305
+ const controller = new AbortController();
306
+ state.plannerAbort = controller;
307
+ try {
308
+ const result = await runHelper("planner", invocation, {
309
+ cwd: input.cwd, signal: controller.signal, timeoutMs: PLANNER_TIMEOUT_MS,
310
+ });
311
+ if (result.failed) return { error: result.text };
312
+ return parsePlannerOutput(result.text, input.hordes);
313
+ } finally {
314
+ state.plannerAbort = undefined;
315
+ }
316
+ }
package/src/run/types.ts CHANGED
@@ -11,6 +11,10 @@ export interface HordeRun extends MemberRunInfo {
11
11
  controllers: Map<string, AbortController>;
12
12
  procs: Map<string, ChildProcess>;
13
13
  cwd: string;
14
+ /** 1-based position in the swarm; the horde is addressed as `h<N>`. */
15
+ horde: number;
16
+ /** The horde's workstream when the swarm partitioned the task; every member sees it. */
17
+ slice?: string | undefined;
14
18
  /** Highest fact id at run start, to separate this run's facts from carried context. */
15
19
  startFactId: number;
16
20
  /** Highest idea id at run start; ideas created above it are this run's coverage. */
@@ -47,6 +51,12 @@ export interface HordeRun extends MemberRunInfo {
47
51
  deliverableAbort?: AbortController | undefined;
48
52
  /** Steps still running after members finished, e.g. ["deliverable", "post-mortem"]. */
49
53
  finalizing?: string[] | undefined;
54
+ /** The run's outcome label once REPORT.md is written, e.g. "solved" or "stopped-clean". */
55
+ outcome?: string | undefined;
56
+ /** True once members exited and REPORT.md is written; the swarm slot frees when every run is. */
57
+ reported?: boolean | undefined;
58
+ /** True once finalize completed, synthesis included. */
59
+ finalized?: boolean | undefined;
50
60
  /** Workstream partition when the run was started with `plan`. */
51
61
  plan?: string[] | undefined;
52
62
  /** Member carrying the verifier role, when one was assigned. */
@@ -79,6 +89,38 @@ export interface HordeRun extends MemberRunInfo {
79
89
  solvedCoverageNotice?: boolean | undefined;
80
90
  postmortemAbort?: AbortController | undefined;
81
91
  ui?: ExtensionUIContext;
92
+ }
93
+
94
+ /**
95
+ * H concurrent hordes on one task. A plain `12` is a swarm of one whose run lives in
96
+ * `.horde/runs/`; `3/12` gives each horde its own ledgers under `.horde/swarms/<id>/h<N>/`.
97
+ */
98
+ export interface Swarm {
99
+ id: string;
100
+ /** `.horde/swarms/<id>`; undefined for a swarm of one. */
101
+ dir: string | undefined;
102
+ /** The project's `.horde` directory. */
103
+ hordeDir: string;
104
+ cwd: string;
105
+ prompt: string;
106
+ hordes: number;
107
+ /** Members per horde. */
108
+ count: number;
109
+ startedAt: number;
110
+ /** One run per horde, `h1` first. */
111
+ runs: HordeRun[];
112
+ /** The `plan` layers as given, before they were dealt to the hordes. */
113
+ plan?: string[] | undefined;
114
+ /** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
115
+ partition?: string[] | undefined;
116
+ untilStop?: boolean | undefined;
117
+ dashboardUrl?: string | undefined;
118
+ /** Generated merged deliverable across the hordes' deliverables. */
119
+ deliverable?: string | undefined;
120
+ deliverableAbort?: AbortController | undefined;
121
+ /** The swarm step still running after every horde finalized, e.g. ["merge"]. */
122
+ finalizing?: string[] | undefined;
123
+ ui?: ExtensionUIContext;
82
124
  tui?: TUI;
83
125
  timer?: ReturnType<typeof setInterval> | undefined;
84
126
  }
@@ -96,13 +138,15 @@ export interface ReportDetails {
96
138
  /** Parent-side extension state shared by the command, run lifecycle, and shutdown. */
97
139
  export interface HordeState {
98
140
  pi: ExtensionAPI;
99
- /** The run whose members are live; at most one at a time. */
100
- active: HordeRun | undefined;
101
- /** A run whose members finished but whose synthesis helpers are still running. */
102
- finalizing: HordeRun | undefined;
141
+ /** The swarm whose members are live; at most one at a time. */
142
+ active: Swarm | undefined;
143
+ /** A swarm whose members finished but whose synthesis helpers are still running. */
144
+ finalizing: Swarm | undefined;
103
145
  dashboard: DashboardServer | undefined;
104
- /** Set synchronously at startRun entry so a same-tick second start is refused. */
146
+ /** Set synchronously at startSwarm entry so a same-tick second start is refused. */
105
147
  starting: boolean;
148
+ /** The planner partitioning a task between hordes, so shutdown can end it. */
149
+ plannerAbort?: AbortController | undefined;
106
150
  /** Set at session shutdown; lifecycle steps check it and stop early. */
107
151
  disposed: boolean;
108
152
  }
@@ -117,3 +161,18 @@ export function createHordeState(pi: ExtensionAPI): HordeState {
117
161
  disposed: false,
118
162
  };
119
163
  }
164
+
165
+ /** `h2` for the second horde; the label every notification, target, and file uses. */
166
+ export function hordeLabel(horde: number): string {
167
+ return `h${horde}`;
168
+ }
169
+
170
+ /** Runs of the swarm whose members are not all finished and reported. */
171
+ export function activeRuns(swarm: Swarm): HordeRun[] {
172
+ return swarm.runs.filter((run) => run.reported !== true);
173
+ }
174
+
175
+ /** Runs of the swarm whose members finished but whose synthesis is still running. */
176
+ export function finalizingRuns(swarm: Swarm): HordeRun[] {
177
+ return swarm.runs.filter((run) => run.reported === true && run.finalized !== true);
178
+ }