agent-horde 0.1.5 → 0.1.6

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.
@@ -1,8 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import { oneLine } from "../format.js";
3
+ import { listClaims } from "./claims.js";
4
+ import { CLUSTER_THRESHOLD } from "./clusters.js";
3
5
  import { appendEntry, hordePaths, requireText, writeFileAtomic } from "./files.js";
6
+ import { listIdeas } from "./ideas.js";
4
7
  import { withHordeLock } from "./lock.js";
5
- import { bestTextMatch, factAnchors, salientTokens, stripLeadingId } from "./text-match.js";
8
+ import { bestTextMatch, factAnchors, hardOverlap, salientTokens, stripLeadingId, textOverlap } from "./text-match.js";
6
9
 
7
10
  export interface FactEntry {
8
11
  id: number;
@@ -10,6 +13,8 @@ export interface FactEntry {
10
13
  author: string;
11
14
  deltaOf?: number | undefined;
12
15
  corroborators: string[];
16
+ /** The open idea (`#12`) or contested claim (`C3`) that was still auditing the subject when the fact was filed. */
17
+ provisionalOn?: string | undefined;
13
18
  headingLine: number;
14
19
  }
15
20
 
@@ -23,18 +28,58 @@ export function parseFacts(content: string): FactEntry[] {
23
28
  const meta = match[4] ?? "";
24
29
  const delta = /delta=#?T?(\d+)/.exec(meta);
25
30
  const corr = /corr=([^\s]+)/.exec(meta);
31
+ const prov = /prov=([^\s]+)/.exec(meta);
26
32
  facts.push({
27
33
  id: Number(match[1]),
28
34
  text: match[2] ?? "",
29
35
  author: match[3] ?? "",
30
36
  deltaOf: delta ? Number(delta[1]) : undefined,
31
37
  corroborators: corr?.[1] ? corr[1].split(",").map((s) => s.trim()).filter(Boolean) : [],
38
+ provisionalOn: prov?.[1],
32
39
  headingLine: lineIndex,
33
40
  });
34
41
  });
35
42
  return facts;
36
43
  }
37
44
 
45
+ /**
46
+ * A fact asserting what an open idea is still auditing, or what a contested claim disputes, is
47
+ * provisional: it is recorded, but the answer may not quote it as settled until the idea closes
48
+ * or the claim resolves. Returns the subject it is provisional on, if any.
49
+ */
50
+ function provisionalSubject(hordeDir: string, text: string): string | undefined {
51
+ const matches = (candidates: string[]): number | undefined => {
52
+ const match = textOverlap(candidates, text);
53
+ if (!match) return undefined;
54
+ return hardOverlap(match) || Math.max(match.jaccard, match.overlap) >= CLUSTER_THRESHOLD ? match.index : undefined;
55
+ };
56
+ const open = listIdeas(hordeDir).filter((idea) => idea.mark === " " || idea.mark === "~");
57
+ const idea = matches(open.map((entry) => entry.text));
58
+ if (idea !== undefined) return `#${open[idea]?.id}`;
59
+ const contested = listClaims(hordeDir).filter((claim) => claim.state === "contested");
60
+ const claim = matches(contested.map((entry) => entry.statement));
61
+ return claim === undefined ? undefined : `C${contested[claim]?.id}`;
62
+ }
63
+
64
+ /** Whether a fact's provisional subject is still open: an unclosed idea or a still-contested claim. */
65
+ export function factUnsettled(hordeDir: string, fact: FactEntry): boolean {
66
+ if (!fact.provisionalOn) return false;
67
+ if (fact.provisionalOn.startsWith("#")) {
68
+ const idea = listIdeas(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
69
+ return idea !== undefined && (idea.mark === " " || idea.mark === "~");
70
+ }
71
+ const claim = listClaims(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
72
+ return claim?.state === "contested";
73
+ }
74
+
75
+ /** T# ids a text quotes whose provisional subject has not closed, each named with that subject. */
76
+ export function unsettledFactCitations(hordeDir: string, text: string): string[] {
77
+ const cited = new Set([...text.matchAll(/\bT(\d+)\b/g)].map((m) => Number(m[1])));
78
+ return listFacts(hordeDir)
79
+ .filter((fact) => cited.has(fact.id) && factUnsettled(hordeDir, fact))
80
+ .map((fact) => `T${fact.id} (provisional on ${fact.provisionalOn})`);
81
+ }
82
+
38
83
  /** Record a second reporter of the same fact on its heading, mirroring findings. */
39
84
  function addFactCorroborator(content: string, entry: FactEntry, author: string): string {
40
85
  if (entry.author === author || entry.corroborators.includes(author)) return content;
@@ -46,9 +91,10 @@ function addFactCorroborator(content: string, entry: FactEntry, author: string):
46
91
  return lines.join("\n");
47
92
  }
48
93
 
49
- function factEntry(author: string, text: string, id: number, deltaOf?: number): string {
94
+ function factEntry(author: string, text: string, id: number, deltaOf?: number, provisionalOn?: string): string {
50
95
  const delta = deltaOf !== undefined ? ` delta=#T${deltaOf}` : "";
51
- return `\n- [T${id}] ${text} @${author}${delta}\n`;
96
+ const prov = provisionalOn ? ` prov=${provisionalOn}` : "";
97
+ return `\n- [T${id}] ${text} @${author}${delta}${prov}\n`;
52
98
  }
53
99
 
54
100
  interface AddFactResult {
@@ -117,8 +163,9 @@ export async function addFact(
117
163
  if (hit) return accrete(hit);
118
164
  }
119
165
  const id = facts.reduce((max, fact) => Math.max(max, fact.id), 0) + 1;
120
- appendEntry(factsPath, factEntry(author, body, id, deltaOf));
121
- return { fact: { id, text: body, author, deltaOf, corroborators: [], headingLine: -1 } };
166
+ const provisionalOn = provisionalSubject(hordeDir, body);
167
+ appendEntry(factsPath, factEntry(author, body, id, deltaOf, provisionalOn));
168
+ return { fact: { id, text: body, author, deltaOf, corroborators: [], provisionalOn, headingLine: -1 } };
122
169
  });
123
170
  }
124
171
 
@@ -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. */
@@ -81,6 +83,8 @@ export interface MemberRunInfo {
81
83
  runDir: string;
82
84
  prompt: string;
83
85
  count: number;
86
+ /** True when started with `--until-stop`: the run continues until the user stops it. */
87
+ untilStop?: boolean | undefined;
84
88
  }
85
89
 
86
90
  interface SpawnMemberInput {
@@ -283,11 +287,19 @@ export function memberSessionDir(runDir: string, member: string): string {
283
287
  return path.join(runDir, "sessions", member);
284
288
  }
285
289
 
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 {
290
+ /** What an `--until-stop` member does instead of stopping when the board runs dry. */
291
+ export const UNTIL_STOP_NOTE =
292
+ "This run continues until the user stops it. When nothing worthwhile remains on the board, add " +
293
+ "ideas that extend, stress-test, or deepen the work and pursue them instead of stopping. Propose " +
294
+ "SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
295
+ "materially better one exists.";
296
+
297
+ /** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
298
+ function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
288
299
  const parts = [`You are ${member.name}.`];
289
300
  if (role) parts.push(role);
290
301
  if (member.resetNote) parts.push(member.resetNote);
302
+ if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
291
303
  return parts.join(" ");
292
304
  }
293
305
 
@@ -297,7 +309,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
297
309
  "--mode", "json", "-p",
298
310
  "--session-dir", memberSessionDir(run.runDir, member.name),
299
311
  "--append-system-prompt", path.join(run.runDir, "SYSTEM.md"),
300
- "--append-system-prompt", identityLine(member, input.role),
312
+ "--append-system-prompt", identityLine(run, member, input.role),
301
313
  ];
302
314
  if (input.model) args.push("--model", `${input.model.provider}/${input.model.id}`);
303
315
  if (input.thinkingLevel) args.push("--thinking", input.thinkingLevel);
package/src/prompt.ts CHANGED
@@ -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.
@@ -172,7 +208,9 @@ Protocol:
172
208
  deletion is refused: reproducers under the run artifacts directory are retained as evidence.
173
209
  4. Record each checkable environment fact once with 'horde fact' (include the command and the
174
210
  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.
211
+ Read FACTS.md before re-deriving anything, and do not duplicate it. A fact about what an open
212
+ idea is still auditing, or what a contested claim disputes, is recorded as provisional on it:
213
+ the answer may not quote it until that idea closes or the claim resolves.
176
214
  5. Record each finding with 'horde finding'. The claimed idea id and a concern slug are required.
177
215
  A concern is a short stable slug for the subject or issue, for example auth-token-refresh or
178
216
  chapter-3-voice; different subjects or entry points need different concerns and separate
@@ -185,6 +223,10 @@ Protocol:
185
223
  from primary documents, or speculative. If the tool
186
224
  returns an existing F#, cite that id and file only distinct evidence with delta-of: F# and the
187
225
  exact same concern. Prefer attach for new evidence on an existing finding; stronger follow-up reviews are welcome.
226
+ Before reproducing an existing F#, run 'horde findings': one with a corroborator or a full-scope
227
+ review is settled, and a further reproduction is refused. Bring a new vector or distinct evidence
228
+ as delta-of: F#, or take a different slice. Register the harness, driver, lab or corpus you build
229
+ as an R# before others need it, with its path in note, so they reuse it instead of rewriting it.
188
230
  If your finding corrects or overturns an earlier finding, pass supersedes: F#. Cite R# for every run artifact. If it contradicts an earlier
189
231
  finding, use supersedes: F#, or record a claim with assert/contest, instead of leaving the
190
232
  correction in prose. Independent review is recorded by another member at the exact revision.
@@ -192,8 +234,9 @@ Protocol:
192
234
  inconclusive reviews remain reviewed-with-gaps. Neither a review count nor full claim coverage
193
235
  automatically establishes runtime exploitation. Keep source checks, controlled tests, and
194
236
  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.
237
+ 6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it; 'done'
238
+ with a negative result line is refused. Contradicting evidence attached to an idea is a rule-out
239
+ candidate for its owner. Use 'horde drop' only to return work you are not pursuing to the open pool.
197
240
  7. When two members actually disagree on a hypothesis, 'horde assert' it and use
198
241
  'confirm'/'contest'/'retract' with primary evidence. Do not use CLAIMS.md for work ownership.
199
242
  8. Post progress and dead ends in one or two lines so others do not repeat them. Re-read
@@ -207,8 +250,12 @@ Protocol:
207
250
  Continue distinct claimed work while another member validates it. For a broad task,
208
251
  one finding is never the complete answer: every current-run priority idea must first be done or
209
252
  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.
253
+ 'horde contest-solved' with what is missing, naming the open ideas (#12, #14) that block it: the
254
+ proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
255
+ from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
256
+ a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
257
+ pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
258
+ the proposer or a validator about the pending answer (target=answer) is served with the blockers.
212
259
  The parent enforces coverage before wind-down.
213
260
 
214
261
  ${COLLABORATION_PROTOCOL}
@@ -227,6 +274,7 @@ export function taskMarkdown(input: {
227
274
  thinkingLevel?: string | undefined;
228
275
  startedAt: number;
229
276
  verifier?: string | undefined;
277
+ untilStop?: boolean | undefined;
230
278
  }): string {
231
279
  return [
232
280
  `# Horde run ${input.id}`,
@@ -236,6 +284,7 @@ export function taskMarkdown(input: {
236
284
  `Model: ${input.model ?? "parent default"}`,
237
285
  `Thinking: ${input.thinkingLevel ?? "parent default"}`,
238
286
  `Verifier: ${input.verifier ?? "none"}`,
287
+ `Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
239
288
  `Task-SHA256: ${taskHash(input.prompt)}`,
240
289
  "",
241
290
  "## Prompt",
@@ -315,7 +364,7 @@ function memberElapsed(m: MemberState, now: number): string {
315
364
  function memberSummarySection(
316
365
  m: MemberState,
317
366
  now: number,
318
- stats?: { findings: number; verified: number; reviewed: number; ideasClosed: number },
367
+ stats?: MemberContribution,
319
368
  resets: MemberReset[] = [],
320
369
  memberPosts?: Map<string, string>,
321
370
  ): string[] {
@@ -324,9 +373,17 @@ function memberSummarySection(
324
373
  lines.push(`${m.turns} turns, ${m.toolCalls} tool calls, ${tokens}, ${memberElapsed(m, now)}`);
325
374
  if (stats) {
326
375
  lines.push(
327
- `${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped), ${stats.verified} legacy verification labels, ` +
376
+ `${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped: ${stats.fullScope} full, ` +
377
+ `${stats.reviewed - stats.fullScope} with unchecked scope), ${stats.verified} legacy verification labels, ` +
328
378
  `${stats.ideasClosed} claimed idea(s) closed`,
329
379
  );
380
+ // The coverage sentence comes from the review records, not from the member's prose: a
381
+ // verifier's "all work is verified" over twelve reviews with unchecked scope stated zero.
382
+ if (stats.verifier) {
383
+ const total = stats.reviewed + stats.unreviewed.length;
384
+ const owed = stats.unreviewed.length ? `; unreviewed: ${stats.unreviewed.map((id) => `F${id}`).join(", ")}` : "";
385
+ lines.push(`verifier coverage from review records: ${stats.reviewed} of ${total} other members' current findings reviewed, ${stats.fullScope} at full scope${owed}`);
386
+ }
330
387
  }
331
388
  if (resets.length > 0) {
332
389
  lines.push(`resets: ${resets.length} (${resets.map((reset) => reset.reason).join("; ")})`);
@@ -411,7 +468,7 @@ export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()):
411
468
  const ideas = run.ideas ?? [];
412
469
  const haveLedger = findings.length > 0 || ideas.length > 0;
413
470
  for (const m of run.members) {
414
- const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews) : undefined;
471
+ const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews, m.name === run.verifier) : undefined;
415
472
  lines.push(...memberSummarySection(m, now, stats, resetsOf(m.name, run.resets), run.memberPosts));
416
473
  }
417
474
  return lines.join("\n");
@@ -25,7 +25,12 @@ const NO_VERIFIER_COMPLETION = {
25
25
  description: "do not assign the verifier role to the last member of a 3+ member run",
26
26
  };
27
27
 
28
- const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION];
28
+ const UNTIL_STOP_COMPLETION = {
29
+ value: "--until-stop ",
30
+ description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
31
+ };
32
+
33
+ const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION];
29
34
 
30
35
  /** Argument completions that are not member targets. */
31
36
  const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
@@ -20,7 +20,7 @@ import { clusterFindings } from "../ledger/clusters.js";
20
20
  import { listFacts } from "../ledger/facts.js";
21
21
  import type { FactEntry } from "../ledger/facts.js";
22
22
  import { readHordeFile } from "../ledger/files.js";
23
- import { listFindingsForRun, parseFindings } from "../ledger/findings.js";
23
+ import { listFindingsForRun, parseFindings, supersededCitations } from "../ledger/findings.js";
24
24
  import type { FindingEntry } from "../ledger/findings.js";
25
25
  import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
26
26
  import type { Idea } from "../ledger/ideas.js";
@@ -109,6 +109,11 @@ function settleSolved(run: HordeRun): SettledAnswer | undefined {
109
109
  ) {
110
110
  run.solvedValidatedBy = solvedState.confirm.member;
111
111
  }
112
+ // `--until-stop`: the last banked answer stands unless a later proposal was validated too.
113
+ if (run.lastRound && !(run.solved && run.solvedValidatedBy)) {
114
+ run.solved = run.lastRound.proposal;
115
+ run.solvedValidatedBy = run.lastRound.validator;
116
+ }
112
117
  if (run.solved) {
113
118
  const status = run.solvedValidatedBy
114
119
  ? `validated by ${run.solvedValidatedBy}`
@@ -132,12 +137,6 @@ interface SettledAnswer {
132
137
  }
133
138
 
134
139
  /** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
135
- export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
136
- return findings
137
- .filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
138
- .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
139
- }
140
-
141
140
  /**
142
141
  * The proposal body is the member's verbatim text and can predate later challenges and corrections.
143
142
  * The status names the blockers still open at freeze and the findings a later finding superseded,
package/src/run/launch.ts CHANGED
@@ -194,7 +194,7 @@ async function launchRun(
194
194
  try {
195
195
  await seedRun(run);
196
196
  await recordLifecycle(run, "run-start", {
197
- count: run.count, model: run.model, verifier: run.verifier, plan: run.plan,
197
+ count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
198
198
  roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
199
199
  });
200
200
  await recordPhase(run, "discovery", "start");
package/src/run/repair.ts CHANGED
@@ -13,9 +13,12 @@ import { scheduleCollaboration, stopReason } from "./schedule.js";
13
13
  import { spawnOne } from "./supervise.js";
14
14
  import type { RunLaunch } from "./supervise.js";
15
15
 
16
- /** Blockers and independent checks take precedence over administrative exchanges. */
16
+ /**
17
+ * Blockers and independent checks take precedence over administrative exchanges. A request to
18
+ * validate the pending answer decides the run's outcome, so it ranks with the blockers.
19
+ */
17
20
  function repairPriority(message: PeerMessage): number {
18
- if (message.blocking) return 0;
21
+ if (message.blocking || message.target === "answer") return 0;
19
22
  if (message.kind === "review-request" && /^[AF]\d+$/.test(message.target)) return 1;
20
23
  if (message.state === "responded") return 2;
21
24
  return 3;
@@ -33,7 +36,7 @@ function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
33
36
  * three reviews demands three, so its share is sized to the work rather than to the member.
34
37
  */
35
38
  export function repairDemand(queue: PeerMessage[]): number {
36
- const checks = queue.filter((m) => m.blocking || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
39
+ const checks = queue.filter((m) => m.blocking || m.target === "answer" || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
37
40
  return Math.max(1, checks);
38
41
  }
39
42
 
@@ -135,8 +138,12 @@ interface FirstTurnCost {
135
138
  cacheWrite: number;
136
139
  }
137
140
 
138
- /** A finished worker receives a fresh, bounded attempt with a precise evidence handoff. */
139
- async function repairMember(launch: RunLaunch, member: MemberState, deadline: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
141
+ /**
142
+ * A finished worker receives a fresh, bounded attempt with a precise evidence handoff. The time
143
+ * share is the allowance recorded and the deadline is derived from it once, so the record cannot
144
+ * read a millisecond under the floor the share was raised to.
145
+ */
146
+ async function repairMember(launch: RunLaunch, member: MemberState, ms: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
140
147
  const { run } = launch;
141
148
  const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
142
149
  const queue = queueFor(launch, member);
@@ -146,7 +153,8 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
146
153
  member.repairs = (member.repairs ?? 0) + 1;
147
154
  member.repairStopReason = undefined;
148
155
  member.attempt = randomUUID();
149
- const allowance = { ...limits, ms: Math.max(0, deadline - Date.now()) };
156
+ const deadline = Date.now() + ms;
157
+ const allowance = { ...limits, ms };
150
158
  let firstTurn: FirstTurnCost | undefined;
151
159
  member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${allowance.tokens} input+output tokens, ${allowance.turns} turns and ${allowance.ms}ms. ` +
152
160
  `Read horde inbox and referenced evidence. Work in this priority order: ${queue.map((m) => `${m.id} ${m.target} (${m.state})`).join(", ")}. ` +
@@ -264,7 +272,7 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
264
272
  }
265
273
  await begin(budget);
266
274
  served.add(next.member.name);
267
- await repairMember(launch, next.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
275
+ await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
268
276
  }
269
277
  }
270
278
  }