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.
@@ -242,7 +242,7 @@ function findingEntry(
242
242
  if (meta.concern) tag += ` concern=${meta.concern}`;
243
243
  if (meta.deltaOf !== undefined) tag += ` delta=#F${meta.deltaOf}`;
244
244
  if (meta.supersedes !== undefined) tag += ` supersedes=#F${meta.supersedes}`;
245
- return `\n### ${formatClock(now)} ${author}${tag}\n${neutralizeHeadings(text.trim())}\n`;
245
+ return `\n### ${formatClock(now)} ${author}${tag}\n${neutralizeNotes(neutralizeHeadings(text.trim()))}\n`;
246
246
  }
247
247
 
248
248
  /** Record another independent member on an existing finding's heading. */
@@ -495,6 +495,96 @@ export async function verifyFinding(
495
495
  });
496
496
  }
497
497
 
498
+ interface AmendFindingInput {
499
+ id: number;
500
+ /** The whole replacement text: title line first, as for `finding`. */
501
+ text: string | undefined;
502
+ /** Why the prose changed; recorded in the body so the revision carries its own errata note. */
503
+ note: string | undefined;
504
+ }
505
+
506
+ /**
507
+ * Rewrite a finding's prose in place at its current revision. Evidence appends cannot fix a
508
+ * sentence and `supersedes` replaces whole findings, so a refuted sentence stayed in FINDINGS.md
509
+ * at freeze. The subject hash changes, reviews of the old revision stay pinned to it, and
510
+ * reviewers reattest when what they checked is unchanged. Verification notes are kept.
511
+ */
512
+ export async function amendFinding(
513
+ hordeDir: string,
514
+ runId: string,
515
+ member: string,
516
+ { id, text, note }: AmendFindingInput,
517
+ ): Promise<{ finding: FindingEntry; previous: string; titleChanged: boolean }> {
518
+ const body = stripLeadingId(requireText(text, "amend")).trim();
519
+ const reason = oneLineReason(note);
520
+ return withHordeLock(hordeDir, member, () => {
521
+ const findingsPath = hordePaths(hordeDir).findings;
522
+ const content = readStampedFindings(findingsPath);
523
+ const all = parseFindings(content);
524
+ const entry = all.find((f) => f.runId === runId && f.id === id);
525
+ if (!entry) throw new Error(`horde: finding F${id} is not in run ${runId}; amend only this run's findings`);
526
+ if (entry.author !== member) throw new Error(`horde: F${id} belongs to ${entry.author}; a correction by another member is a finding with supersedes: ${id}`);
527
+ if (entry.supersededBy !== undefined) throw new Error(`horde: F${id} was superseded by F${entry.supersededBy}; amend the successor`);
528
+ const scoped = all.filter((f) => f.runId === runId && f.id !== id);
529
+ checkBodyReferences(scoped, { runId, author: member, body, meta: { supersedes: entry.supersedes, deltaOf: entry.deltaOf } });
530
+ const previous = findingRevision(entry);
531
+ const subjectEnd = entry.body.search(/\n(?:Verified|Reviewed) by /);
532
+ // Verification notes were written against the previous subject; they stay as history, labelled
533
+ // as such, and the heading's legacy verified label goes so the new text reads as unreviewed.
534
+ const notes = subjectEnd === -1 ? "" : `\n${entry.body.slice(subjectEnd)}`;
535
+ const predate = notes ? ` (verification notes below were recorded against revision ${previous.slice(0, 12)})` : "";
536
+ const amended = `${neutralizeNotes(neutralizeHeadings(body))}\nAmended ${formatClock(new Date())} by ${member}: ${reason}${predate}${notes}`;
537
+ const lines = content.split("\n");
538
+ // Corroborators and the legacy verifier confirmed the previous text; they confirm the new one again.
539
+ lines[entry.headingLine] = (lines[entry.headingLine] ?? "").replace(/\s+(?:verified|vsupport|corr)=[^\s]+/g, "");
540
+ lines.splice(entry.headingLine + 1, entry.body.split("\n").length, ...amended.split("\n"));
541
+ writeFileAtomic(findingsPath, lines.join("\n"));
542
+ const title = amended.split("\n")[0] ?? "";
543
+ const finding = { ...entry, body: amended, title, verifiedBy: undefined, verifiedSupport: undefined, corroborators: [] };
544
+ return { finding, previous, titleChanged: title.trim() !== entry.title.trim() };
545
+ });
546
+ }
547
+
548
+ function oneLineReason(note: string | undefined): string {
549
+ const reason = (note ?? "").replace(/\s+/g, " ").trim();
550
+ if (!reason) throw new Error("horde: amend needs note=<why the prose changed>, so the revision carries its errata");
551
+ return reason.slice(0, 300);
552
+ }
553
+
554
+ /** Superseded findings a text still cites, each with its successor. */
555
+ export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
556
+ return findings
557
+ .filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
558
+ .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
559
+ }
560
+
561
+ /** The current revision of every live finding a text cites, pinned so a later amend is visible. */
562
+ export function answerCitations(findings: FindingEntry[], text: string): Record<string, string> {
563
+ const cites: Record<string, string> = {};
564
+ for (const finding of findings) {
565
+ if (finding.supersededBy === undefined && new RegExp(`\\bF${finding.id}\\b`).test(text)) cites[`F${finding.id}`] = findingRevision(finding);
566
+ }
567
+ return cites;
568
+ }
569
+
570
+ /**
571
+ * Citations an answer can no longer stand on: findings superseded since it was written, and
572
+ * findings amended since the revisions it pinned. Both ship a stale reading as the result.
573
+ */
574
+ export function staleAnswerCitations(findings: FindingEntry[], text: string, cites: Record<string, string> = {}): string[] {
575
+ const stale = supersededCitations(text, findings).map((citation) => `superseded ${citation}`);
576
+ for (const [target, revision] of Object.entries(cites)) {
577
+ const finding = findings.find((entry) => `F${entry.id}` === target);
578
+ if (finding && finding.supersededBy === undefined && findingRevision(finding) !== revision) stale.push(`amended ${target}`);
579
+ }
580
+ return stale;
581
+ }
582
+
583
+ /** A body line that reads as a verification note would move the subject boundary the revision hashes. */
584
+ function neutralizeNotes(text: string): string {
585
+ return text.replace(/^((?:Verified|Reviewed) by )/gm, "\\$1");
586
+ }
587
+
498
588
  export function listFindingsForRun(hordeDir: string, runId: string): FindingEntry[] {
499
589
  const findingsPath = hordePaths(hordeDir).findings;
500
590
  if (!fs.existsSync(findingsPath)) return [];
@@ -81,6 +81,8 @@ export interface SolvedSignal {
81
81
  member: string;
82
82
  text: string;
83
83
  at: string;
84
+ /** Revision of each finding the text cited when it was proposed, so a later amend is visible. */
85
+ cites?: Record<string, string>;
84
86
  }
85
87
 
86
88
  function solvedPath(runDir: string): string {
@@ -127,7 +129,8 @@ function readSignal(file: string): SolvedSignal | undefined {
127
129
  try {
128
130
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<SolvedSignal>;
129
131
  if (typeof parsed?.member === "string" && typeof parsed?.text === "string") {
130
- return { member: parsed.member, text: parsed.text, at: parsed.at ?? "", revision: parsed.revision };
132
+ const cites = parsed.cites && typeof parsed.cites === "object" ? parsed.cites : undefined;
133
+ return { member: parsed.member, text: parsed.text, at: parsed.at ?? "", revision: parsed.revision, ...(cites ? { cites } : {}) };
131
134
  }
132
135
  } catch {
133
136
  // No signal, or a torn write; treat as none.
@@ -195,6 +198,7 @@ export async function markSolved(
195
198
  runId: string,
196
199
  member: string,
197
200
  text: string | undefined,
201
+ cites?: Record<string, string>,
198
202
  ): Promise<{ signal: SolvedSignal; alreadySolved: boolean }> {
199
203
  // The full answer is stored untruncated; only the board line is shortened.
200
204
  const full = requireText(text, "solved").trim();
@@ -203,7 +207,7 @@ export async function markSolved(
203
207
  return withHordeLock(hordeDir, member, () => {
204
208
  const existing = readSolved(runDir);
205
209
  if (existing) return { signal: existing, alreadySolved: true };
206
- const signal: SolvedSignal = { member, text: full, at: new Date().toISOString(), revision: randomUUID() };
210
+ const signal: SolvedSignal = { member, text: full, at: new Date().toISOString(), revision: randomUUID(), ...(cites ? { cites } : {}) };
207
211
  writeFileAtomic(solvedPath(runDir), `${JSON.stringify(signal)}\n`);
208
212
  // Capture the answer immediately so it survives even if the run is killed
209
213
  // before finalize; the status is upgraded on validation and at finalize.
@@ -213,6 +217,17 @@ export async function markSolved(
213
217
  });
214
218
  }
215
219
 
220
+ /**
221
+ * Put a withdrawn or contested proposal back before the members, under a new revision so a
222
+ * confirmation of the old one cannot validate it. Call under the horde lock.
223
+ */
224
+ export function reopenSolved(runDir: string, runId: string, proposal: SolvedSignal, status: string, cites?: Record<string, string>): SolvedSignal {
225
+ const signal: SolvedSignal = { member: proposal.member, text: proposal.text, at: new Date().toISOString(), revision: randomUUID(), ...(cites ? { cites } : {}) };
226
+ writeFileAtomic(solvedPath(runDir), `${JSON.stringify(signal)}\n`);
227
+ writeAnswer(runDir, runId, signal, status);
228
+ return signal;
229
+ }
230
+
216
231
  interface SolvedAnswer {
217
232
  ok: boolean;
218
233
  reason: string;
package/src/member.ts CHANGED
@@ -55,6 +55,8 @@ export interface MemberState {
55
55
  summaryIncomplete?: boolean | undefined;
56
56
  /** How many fresh-context sessions replaced this member's earlier ones. */
57
57
  resets?: number | undefined;
58
+ /** Fresh sessions an `--until-stop` run gave this member after an earlier one finished. */
59
+ continuations?: number | undefined;
58
60
  /** Set by the parent before it kills a stalled member, so the exit is treated as a reset. */
59
61
  resetPending?: string | undefined;
60
62
  /** Extra identity line for a replacement session, naming what was reset and why. */
@@ -75,12 +77,30 @@ export interface MemberReset {
75
77
  tokens: number;
76
78
  }
77
79
 
80
+ /** Another horde of the same swarm, whose ledgers a member may read but never write. */
81
+ export interface SiblingHorde {
82
+ /** `h1`, `h2`, ... */
83
+ label: string;
84
+ hordeDir: string;
85
+ /** That horde's workstream, when the swarm partitioned the task. */
86
+ slice?: string | undefined;
87
+ }
88
+
78
89
  export interface MemberRunInfo {
79
90
  id: string;
80
91
  hordeDir: string;
81
92
  runDir: string;
82
93
  prompt: string;
83
94
  count: number;
95
+ /** True when started with `--until-stop`: the run continues until the user stops it. */
96
+ untilStop?: boolean | undefined;
97
+ /** 1-based horde index in the swarm and the swarm's size; a single run is horde 1 of 1. */
98
+ horde?: number | undefined;
99
+ hordes?: number | undefined;
100
+ /** This horde's workstream, told to every member, when the swarm partitioned the task. */
101
+ slice?: string | undefined;
102
+ /** The other hordes of the swarm. */
103
+ siblings?: SiblingHorde[] | undefined;
84
104
  }
85
105
 
86
106
  interface SpawnMemberInput {
@@ -283,11 +303,31 @@ export function memberSessionDir(runDir: string, member: string): string {
283
303
  return path.join(runDir, "sessions", member);
284
304
  }
285
305
 
286
- /** The per-member system line: name, assigned role, and a reset note for a replacement. */
287
- function identityLine(member: MemberState, role: string | undefined): string {
306
+ /** What an `--until-stop` member does instead of stopping when the board runs dry. */
307
+ export const UNTIL_STOP_NOTE =
308
+ "This run continues until the user stops it. When nothing worthwhile remains on the board, add " +
309
+ "ideas that extend, stress-test, or deepen the work and pursue them instead of stopping. Propose " +
310
+ "SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
311
+ "materially better one exists.";
312
+
313
+ /** What a member of a partitioned swarm is told about its horde's workstream. */
314
+ export function sliceNote(run: MemberRunInfo): string | undefined {
315
+ if (!run.slice || !run.horde || !run.hordes || run.hordes < 2) return undefined;
316
+ return (
317
+ `Your horde is h${run.horde} of ${run.hordes} hordes working the same task in parallel, each in its own ` +
318
+ `ledgers. Your horde owns workstream ${run.horde}: "${run.slice}". Work only on your horde's workstream; ` +
319
+ "do not duplicate another horde's."
320
+ );
321
+ }
322
+
323
+ /** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
324
+ function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
288
325
  const parts = [`You are ${member.name}.`];
326
+ const slice = sliceNote(run);
327
+ if (slice) parts.push(slice);
289
328
  if (role) parts.push(role);
290
329
  if (member.resetNote) parts.push(member.resetNote);
330
+ if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
291
331
  return parts.join(" ");
292
332
  }
293
333
 
@@ -297,7 +337,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
297
337
  "--mode", "json", "-p",
298
338
  "--session-dir", memberSessionDir(run.runDir, member.name),
299
339
  "--append-system-prompt", path.join(run.runDir, "SYSTEM.md"),
300
- "--append-system-prompt", identityLine(member, input.role),
340
+ "--append-system-prompt", identityLine(run, member, input.role),
301
341
  ];
302
342
  if (input.model) args.push("--model", `${input.model.provider}/${input.model.id}`);
303
343
  if (input.thinkingLevel) args.push("--thinking", input.thinkingLevel);
@@ -307,6 +347,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
307
347
  }
308
348
 
309
349
  export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
350
+ const siblings = input.run.siblings ?? [];
310
351
  return childEnv({
311
352
  PI_HORDE_MEMBER: "1",
312
353
  PI_HORDE_DIR: input.run.hordeDir,
@@ -314,9 +355,27 @@ export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
314
355
  PI_HORDE_RUN: input.run.id,
315
356
  PI_HORDE_ROLE: input.role ?? "",
316
357
  PI_HORDE_ATTEMPT: input.member.attempt ??= randomUUID(),
358
+ PI_HORDE_SIBLINGS: siblings.length > 0 ? JSON.stringify(siblings.map((s) => ({ label: s.label, hordeDir: s.hordeDir }))) : "",
317
359
  });
318
360
  }
319
361
 
362
+ /** The sibling hordes a member process was given, from `PI_HORDE_SIBLINGS`; empty outside a swarm. */
363
+ export function parseSiblings(raw: string | undefined): SiblingHorde[] {
364
+ if (!raw) return [];
365
+ try {
366
+ const items = JSON.parse(raw) as unknown;
367
+ if (!Array.isArray(items)) return [];
368
+ return items
369
+ .filter((item): item is { label: string; hordeDir: string } =>
370
+ typeof item === "object" && item !== null &&
371
+ typeof (item as { label?: unknown }).label === "string" &&
372
+ typeof (item as { hordeDir?: unknown }).hordeDir === "string")
373
+ .map((item) => ({ label: item.label, hordeDir: item.hordeDir }));
374
+ } catch {
375
+ return [];
376
+ }
377
+ }
378
+
320
379
  export function splitNdjson(buffer: string, onLine: (line: string) => void): string {
321
380
  const lines = buffer.split("\n");
322
381
  const remainder = lines.pop() ?? "";
package/src/prompt.ts CHANGED
@@ -9,7 +9,7 @@ import type { PeerMessage, ReviewRecord } from "./ledger/collaboration.js";
9
9
  import type { Idea } from "./ledger/ideas.js";
10
10
  import type { ResourceEntry } from "./ledger/resources.js";
11
11
  import type { SolvedSignal } from "./ledger/run-signals.js";
12
- import type { MemberReset, MemberState } from "./member.js";
12
+ import type { MemberReset, MemberState, SiblingHorde } from "./member.js";
13
13
 
14
14
  /** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
15
15
  export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
@@ -58,17 +58,36 @@ export interface RunSummaryInput {
58
58
  memberPosts?: Map<string, string> | undefined;
59
59
  }
60
60
 
61
+ interface MemberContribution {
62
+ findings: number;
63
+ verified: number;
64
+ reviewed: number;
65
+ /** Current-revision reviews by this member with nothing left unchecked. */
66
+ fullScope: number;
67
+ /** Other members' current findings this member has not reviewed at their current revision. */
68
+ unreviewed: number[];
69
+ ideasClosed: number;
70
+ /** True for the member that held the verifier role; its coverage line is printed. */
71
+ verifier: boolean;
72
+ }
73
+
61
74
  /** Findings filed, verified, and claimed ideas closed by one member, for the summary. */
62
75
  function memberContribution(
63
76
  name: string,
64
77
  findings: FindingEntry[],
65
78
  ideas: Idea[],
66
79
  reviews: ReviewRecord[] = [],
67
- ): { findings: number; verified: number; reviewed: number; ideasClosed: number } {
80
+ verifier = false,
81
+ ): MemberContribution {
82
+ const current = findings.filter((f) => f.supersededBy === undefined);
83
+ const ownReview = (f: FindingEntry): ReviewRecord | undefined => findingReviewState(f, reviews).reviews.find((r) => r.actor === name);
68
84
  return {
85
+ verifier,
69
86
  findings: findings.filter((finding) => finding.author === name).length,
70
87
  verified: findings.filter((finding) => finding.verifiedBy === name).length,
71
- reviewed: findings.filter((f) => f.supersededBy === undefined && findingReviewState(f, reviews).reviews.some((r) => r.actor === name)).length,
88
+ reviewed: current.filter((f) => ownReview(f) !== undefined).length,
89
+ fullScope: current.filter((f) => !ownReview(f)?.unchecked.trim() && ownReview(f) !== undefined).length,
90
+ unreviewed: current.filter((f) => f.author !== name && ownReview(f) === undefined).map((f) => f.id),
72
91
  ideasClosed: ideas.filter((idea) => idea.mark === "x" && idea.owner === name).length,
73
92
  };
74
93
  }
@@ -82,8 +101,10 @@ export const VERIFIER_ROLE =
82
101
  "You are the verifier. Reproduce the highest-importance unverified findings first. Record each " +
83
102
  "check with 'horde inspect', 'horde attach', then 'horde review': name the exact revision, assertions " +
84
103
  "checked, unchecked assertions, method, evidence refs and support. Source reasoning is not a live reproduction. When a finding does not reproduce, send a blocking challenge to its owner and contest its claim C# if one exists, or " +
85
- "file a finding with supersedes: F# under the same idea and concern. Do not claim ideas or " +
86
- "file findings of your own unless nothing is left to verify.";
104
+ "file a finding with supersedes: F# under the same idea and concern. The tool refuses your idea claims " +
105
+ "while other members' findings await your review, and a session that closes with high-importance " +
106
+ "findings unreviewed is replaced by a fresh one that owes them. Your summary's coverage sentence is " +
107
+ "derived from your review records, so name what you left unreviewed rather than calling the work verified.";
87
108
 
88
109
  const COLLABORATION_PROTOCOL = `
89
110
  Collaboration loop:
@@ -114,14 +135,29 @@ Collaboration loop:
114
135
  review can accept a document; runnable outputs require tested/reproduced support. Document acceptance
115
136
  does not establish execution or remove finding limitations. Kind is immutable. Updates need target=A#
116
137
  and revision=previous-hash; changed criteria/dependencies require changed file bytes and a fresh review.
117
- Ask a peer to review A# before settling.
118
- - Review with target=F#/A#, revision=current-hash, checked=assertions-and-method, unchecked=remaining-gaps,
119
- verdict=supports|partially-supports|refutes|inconclusive, support=tested|reproduced|derived|sourced,
120
- refs=[E#], text=observations. Empty unchecked means you checked the whole stated claim.
138
+ Ask a peer to review A# before settling. Once other artifacts pin your A# (a spec they build against),
139
+ its revision is a frozen contract: route clarifications to a follow-up, and republish only with text=
140
+ naming the change and whether behaviour is unchanged, so dependents can reattest instead of re-reviewing.
141
+ - Review with target=F#/A#, revision=current-hash, checked=assertions-and-method, unchecked=in-criteria-gaps,
142
+ limits=out-of-criteria-disclosures, verdict=supports|partially-supports|refutes|inconclusive,
143
+ support=tested|reproduced|derived|sourced, refs=[E#], text=observations. Judge unchecked against the
144
+ target's stated claim or criteria: a gap inside them keeps acceptance open until a full review covers it;
145
+ a limit outside them (one engine, no hardened deployment) goes in limits and is recorded on the
146
+ acceptance, never as a blocker. Empty unchecked means you checked the whole stated scope. A partial
147
+ verdict refutes nothing and opens no challenge: the acceptance names what it left unchecked. Only
148
+ refutes opens a challenge to the owner; send a challenge yourself when a partial check found a defect.
121
149
  To avoid a separate attach call, review may include evidenceText=full-observation and optional
122
150
  artifactPath instead of (or alongside) refs. Evidence and review commit atomically with separate E#/V#
123
151
  IDs and the same independence/revision checks. Keep text a decision summary; do not repeat the raw
124
152
  evidence in checked, text and evidenceText. The separate attach/review workflow remains available.
153
+ - When a target you reviewed is re-hashed without changing what you checked (an amended sentence, a
154
+ republish against a corrected dependency, a spec clarification), horde reattest target=F#/A#
155
+ revision=current-hash evidenceText=what-you-compared text=why carries your earlier review forward
156
+ instead of repeating it. Reattest only what is unchanged; a changed claim needs a new review.
157
+ - Fix wrong prose in your own finding with horde amend id=F# text=full-replacement note=why: the
158
+ revision changes, reviews stay pinned to what they checked, and reviewers reattest or re-review.
159
+ A reviewer's unchecked text or refutation that contradicts a sentence of your body is a request to
160
+ amend or supersede, not to leave the sentence standing. Another member corrects you with supersedes.
125
161
  - horde resource-state target=R# resourceState=building|ready|broken|retired text=health-result ports=[port]
126
162
  leaseSeconds=300 generation=last-generation. Ready needs refs=[health-evidence-E#]. Read collaboration first.
127
163
  Only the lease holder mutates a lab. Expired leases require checking its state before takeover.
@@ -133,7 +169,26 @@ Collaboration loop:
133
169
  attempt after discovery exits. Repair attempts must address assigned feedback only.
134
170
  `;
135
171
 
136
- export function memberSystemPrompt(input: { hordeDir: string; count: number }): string {
172
+ /** What a member of a swarm is told about the other hordes: shared read, local write. */
173
+ function siblingsSection(siblings: SiblingHorde[]): string {
174
+ if (siblings.length === 0) return "";
175
+ const lines = siblings.map(
176
+ (sibling) => `- ${sibling.label}: ${sibling.hordeDir}/${sibling.slice ? ` (workstream: ${sibling.slice})` : ""}`,
177
+ );
178
+ return `
179
+ Your horde is one of ${siblings.length + 1} hordes working the same task in parallel, each with its
180
+ own ledgers. The other hordes' directories are read-only for you:
181
+
182
+ ${lines.join("\n")}
183
+
184
+ 'horde facts' and 'horde resources' include their entries tagged [hN], and 'horde list' includes
185
+ their done and ruled-out ideas, so a fact, a rule-out, or a resource another horde recorded is not
186
+ re-derived here; reuse a sibling's resource by its path. Every write goes to your own horde's
187
+ ledgers; never write to a sibling's directory, and never claim or cite a sibling's ideas as yours.
188
+ `;
189
+ }
190
+
191
+ export function memberSystemPrompt(input: { hordeDir: string; count: number; siblings?: SiblingHorde[] | undefined }): string {
137
192
  const count = input.count;
138
193
  return `You are one member of a horde of ${count} agents. Every member received the same task
139
194
  and works in parallel in this repository with its own separate context. Coordinate through the
@@ -152,9 +207,9 @@ horde tool. Durable run events retain messages, evidence, reviews and revisions;
152
207
  - PLAN.md workstream partition and your assigned layer, when the run was started with plan.
153
208
 
154
209
  Read them with the read tool. Write them ONLY with the horde tool; never edit them directly.
155
- Put reproduction snippets under .horde/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so evidence
156
- survives finalization and line references do not drift.
157
-
210
+ Put reproduction snippets under ${input.hordeDir}/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so
211
+ evidence survives finalization and line references do not drift.
212
+ ${siblingsSection(input.siblings ?? [])}
158
213
  Protocol:
159
214
  1. Run 'horde list', 'horde findings', 'horde facts', and read BOARD.md before planning.
160
215
  2. Prefer the shared queue. Add an idea only for an uncovered question or concrete evidence gap.
@@ -172,7 +227,9 @@ Protocol:
172
227
  deletion is refused: reproducers under the run artifacts directory are retained as evidence.
173
228
  4. Record each checkable environment fact once with 'horde fact' (include the command and the
174
229
  observed result). If it extends an existing fact, pass delta-of: T# instead of restating it.
175
- Read FACTS.md before re-deriving anything, and do not duplicate it.
230
+ Read FACTS.md before re-deriving anything, and do not duplicate it. A fact about what an open
231
+ idea is still auditing, or what a contested claim disputes, is recorded as provisional on it:
232
+ the answer may not quote it until that idea closes or the claim resolves.
176
233
  5. Record each finding with 'horde finding'. The claimed idea id and a concern slug are required.
177
234
  A concern is a short stable slug for the subject or issue, for example auth-token-refresh or
178
235
  chapter-3-voice; different subjects or entry points need different concerns and separate
@@ -185,6 +242,10 @@ Protocol:
185
242
  from primary documents, or speculative. If the tool
186
243
  returns an existing F#, cite that id and file only distinct evidence with delta-of: F# and the
187
244
  exact same concern. Prefer attach for new evidence on an existing finding; stronger follow-up reviews are welcome.
245
+ Before reproducing an existing F#, run 'horde findings': one with a corroborator or a full-scope
246
+ review is settled, and a further reproduction is refused. Bring a new vector or distinct evidence
247
+ as delta-of: F#, or take a different slice. Register the harness, driver, lab or corpus you build
248
+ as an R# before others need it, with its path in note, so they reuse it instead of rewriting it.
188
249
  If your finding corrects or overturns an earlier finding, pass supersedes: F#. Cite R# for every run artifact. If it contradicts an earlier
189
250
  finding, use supersedes: F#, or record a claim with assert/contest, instead of leaving the
190
251
  correction in prose. Independent review is recorded by another member at the exact revision.
@@ -192,8 +253,9 @@ Protocol:
192
253
  inconclusive reviews remain reviewed-with-gaps. Neither a review count nor full claim coverage
193
254
  automatically establishes runtime exploitation. Keep source checks, controlled tests, and
194
255
  end-to-end reproductions distinct; legacy verified= headings are not the scoped-review count.
195
- 6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it.
196
- Use 'horde drop' only to return work you are not pursuing to the open pool.
256
+ 6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it; 'done'
257
+ with a negative result line is refused. Contradicting evidence attached to an idea is a rule-out
258
+ candidate for its owner. Use 'horde drop' only to return work you are not pursuing to the open pool.
197
259
  7. When two members actually disagree on a hypothesis, 'horde assert' it and use
198
260
  'confirm'/'contest'/'retract' with primary evidence. Do not use CLAIMS.md for work ownership.
199
261
  8. Post progress and dead ends in one or two lines so others do not repeat them. Re-read
@@ -207,8 +269,12 @@ Protocol:
207
269
  Continue distinct claimed work while another member validates it. For a broad task,
208
270
  one finding is never the complete answer: every current-run priority idea must first be done or
209
271
  ruled out. Call 'horde confirm-solved' only after independent verification, or
210
- 'horde contest-solved' with what is missing. Pass the revision from ANSWER.md or
211
- 'horde inspect' target=answer to either action, so stale reviews cannot approve a newer proposal.
272
+ 'horde contest-solved' with what is missing, naming the open ideas (#12, #14) that block it: the
273
+ proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
274
+ from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
275
+ a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
276
+ pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
277
+ the proposer or a validator about the pending answer (target=answer) is served with the blockers.
212
278
  The parent enforces coverage before wind-down.
213
279
 
214
280
  ${COLLABORATION_PROTOCOL}
@@ -227,7 +293,11 @@ export function taskMarkdown(input: {
227
293
  thinkingLevel?: string | undefined;
228
294
  startedAt: number;
229
295
  verifier?: string | undefined;
296
+ untilStop?: boolean | undefined;
297
+ /** Set for a horde of a larger swarm: which horde this is and its workstream. */
298
+ swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
230
299
  }): string {
300
+ const swarm = input.swarm;
231
301
  return [
232
302
  `# Horde run ${input.id}`,
233
303
  "",
@@ -236,6 +306,9 @@ export function taskMarkdown(input: {
236
306
  `Model: ${input.model ?? "parent default"}`,
237
307
  `Thinking: ${input.thinkingLevel ?? "parent default"}`,
238
308
  `Verifier: ${input.verifier ?? "none"}`,
309
+ `Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
310
+ ...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
311
+ ...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
239
312
  `Task-SHA256: ${taskHash(input.prompt)}`,
240
313
  "",
241
314
  "## Prompt",
@@ -315,7 +388,7 @@ function memberElapsed(m: MemberState, now: number): string {
315
388
  function memberSummarySection(
316
389
  m: MemberState,
317
390
  now: number,
318
- stats?: { findings: number; verified: number; reviewed: number; ideasClosed: number },
391
+ stats?: MemberContribution,
319
392
  resets: MemberReset[] = [],
320
393
  memberPosts?: Map<string, string>,
321
394
  ): string[] {
@@ -324,9 +397,17 @@ function memberSummarySection(
324
397
  lines.push(`${m.turns} turns, ${m.toolCalls} tool calls, ${tokens}, ${memberElapsed(m, now)}`);
325
398
  if (stats) {
326
399
  lines.push(
327
- `${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped), ${stats.verified} legacy verification labels, ` +
400
+ `${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped: ${stats.fullScope} full, ` +
401
+ `${stats.reviewed - stats.fullScope} with unchecked scope), ${stats.verified} legacy verification labels, ` +
328
402
  `${stats.ideasClosed} claimed idea(s) closed`,
329
403
  );
404
+ // The coverage sentence comes from the review records, not from the member's prose: a
405
+ // verifier's "all work is verified" over twelve reviews with unchecked scope stated zero.
406
+ if (stats.verifier) {
407
+ const total = stats.reviewed + stats.unreviewed.length;
408
+ const owed = stats.unreviewed.length ? `; unreviewed: ${stats.unreviewed.map((id) => `F${id}`).join(", ")}` : "";
409
+ lines.push(`verifier coverage from review records: ${stats.reviewed} of ${total} other members' current findings reviewed, ${stats.fullScope} at full scope${owed}`);
410
+ }
330
411
  }
331
412
  if (resets.length > 0) {
332
413
  lines.push(`resets: ${resets.length} (${resets.map((reset) => reset.reason).join("; ")})`);
@@ -411,7 +492,7 @@ export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()):
411
492
  const ideas = run.ideas ?? [];
412
493
  const haveLedger = findings.length > 0 || ideas.length > 0;
413
494
  for (const m of run.members) {
414
- const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews) : undefined;
495
+ const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews, m.name === run.verifier) : undefined;
415
496
  lines.push(...memberSummarySection(m, now, stats, resetsOf(m.name, run.resets), run.memberPosts));
416
497
  }
417
498
  return lines.join("\n");