agent-horde 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run-graph.ts CHANGED
@@ -117,14 +117,14 @@ export function entityHref(runId: string, id: string): string | undefined {
117
117
  if (!match) return undefined;
118
118
  const letter = match[1] ?? "";
119
119
  const number = match[2] ?? "";
120
- const run = encodeURIComponent(runId);
120
+ const base = `/runs/${encodeURIComponent(runId)}`;
121
121
  switch (letter) {
122
- case "I": return `/ideas#idea-${number}`;
123
- case "F": return `/findings?run=${run}#F${number}`;
124
- case "T": return `/facts?run=${run}#T${number}`;
125
- case "C": return `/claims#C${number}`;
126
- case "R": return `/resources#R${number}`;
127
- case "A": case "V": case "Q": return `/runs/${run}/collaboration#${id}`;
122
+ case "I": return `${base}/ideas#idea-${number}`;
123
+ case "F": return `${base}/findings#F${number}`;
124
+ case "T": return `${base}/facts#T${number}`;
125
+ case "C": return `${base}/claims#C${number}`;
126
+ case "R": return `${base}/resources#R${number}`;
127
+ case "A": case "V": case "Q": return `${base}/collaboration#${id}`;
128
128
  case "E": return eventHref(runId, Number(number));
129
129
  default: return undefined;
130
130
  }
@@ -360,10 +360,13 @@ function addReview(draft: Draft, event: HordeEvent, review: Payload): void {
360
360
  if (!id) return;
361
361
  const verdict = str(review.verdict) ?? "review";
362
362
  const support = str(review.support) ?? "";
363
- const scope = `checked: ${str(review.checked) ?? ""}; unchecked: ${str(review.unchecked) || "none declared"}`;
363
+ const limits = str(review.limits) ? `; limits: ${str(review.limits)}` : "";
364
+ const scope = `checked: ${str(review.checked) ?? ""}; unchecked: ${str(review.unchecked) || "none declared"}${limits}`;
364
365
  const title = oneLine(`[${support}] ${scope}. ${str(review.text) ?? ""}`, TITLE_MAX);
365
366
  if (!addEntity(draft, event, { id, kind: "review", label: `${id} ${verdict}`, title })) return;
366
367
  addEdge(draft, id, str(review.target), verdict, relationTone(verdict));
368
+ const reattests = str(review.reattests);
369
+ if (reattests && draft.nodes.has(reattests)) draft.edges.push({ from: reattests, to: id, label: "reattested", tone: "collaboration" });
367
370
  for (const ref of strings(review.evidence)) {
368
371
  if (draft.nodes.has(ref)) draft.edges.push({ from: ref, to: id, label: "cited", tone: "collaboration" });
369
372
  }
@@ -427,7 +430,7 @@ const COLLABORATION: Record<string, Handler> = {
427
430
  function lifecycleText(action: string, p: Payload): [string, string] {
428
431
  const members = strings(p.members).join(", ");
429
432
  switch (action) {
430
- case "run-start": return ["run start", `${num(p.count) ?? "?"} members${str(p.model) ? `, model ${str(p.model)}` : ""}`];
433
+ case "run-start": return ["run start", `${num(p.count) ?? "?"} members${str(p.model) ? `, model ${str(p.model)}` : ""}${p.untilStop === true ? ", until stop" : ""}`];
431
434
  case "run-end": return ["run end", `outcome ${str(p.outcome) ?? "unknown"}`];
432
435
  case "member-spawn": return ["spawn", str(p.role) ? `role: ${oneLine(str(p.role) ?? "", 120)}` : "spawned"];
433
436
  case "member-exit": return [`exit ${str(p.endCause) ?? ""}`.trim(), `status ${str(p.status) ?? "?"}, ${num(p.turns) ?? 0} turns, ${num(p.tokens) ?? 0} tokens`];
@@ -436,7 +439,11 @@ function lifecycleText(action: string, p: Payload): [string, string] {
436
439
  case "stop-hard": return [`stop-hard (${str(p.by) ?? "user"})`, members || "all members"];
437
440
  case "solved-proposed": return ["solved proposed", `${str(p.member) ?? ""}: ${oneLine(str(p.text) ?? "", 120)}`];
438
441
  case "solved-contested": return ["solved contested", `by ${str(p.member) ?? ""}`];
442
+ case "solved-withdrawn": return ["solved withdrawn", oneLine(str(p.reason) ?? "", 120)];
443
+ case "solved-reopened": return ["solved reopened", oneLine(str(p.reason) ?? "", 120)];
439
444
  case "solved-validated": return ["solved validated", str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
445
+ case "solved-banked": return [`solved banked (round ${num(p.round) ?? "?"})`, str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
446
+ case "member-continue": return ["continue", `fresh session ${num(p.continuations) ?? "?"}; released ${strings(p.released).length}, closed ${strings(p.closed).length}`];
440
447
  case "acceptance": return [`acceptance ${str(p.status) ?? ""}`.trim(), `${num(p.issues) ?? 0} issues, snapshot ${(str(p.hash) ?? "").slice(0, 12)}`];
441
448
  case "repair-start": return ["repair start", `queue ${strings(p.messages).join(", ")}`];
442
449
  case "repair-end": return ["repair end", `${str(p.cause) ?? ""}${str(p.stopReason) ? ` (${str(p.stopReason)})` : ""}`];
@@ -0,0 +1,127 @@
1
+ import { oneLine } from "./format.js";
2
+ import type { HordeEvent } from "./ledger/events.js";
3
+ import { LIFECYCLE_ATTEMPT, eventHref } from "./run-graph.js";
4
+ import type { GraphEdge, GraphLane, GraphNode, RunGraph } from "./run-graph.js";
5
+
6
+ /** One horde's events and where its run page is. */
7
+ export interface SwarmGraphHorde {
8
+ label: string;
9
+ runId: string;
10
+ events: HordeEvent[];
11
+ status?: string | undefined;
12
+ }
13
+
14
+ export interface SwarmGraphInput {
15
+ swarmId: string;
16
+ startedAt: number;
17
+ hordes: SwarmGraphHorde[];
18
+ /** The swarm's merge step, once it started. */
19
+ merge?: { startedAt: number; endedAt?: number | undefined; written?: boolean | undefined } | undefined;
20
+ /** Graph end for a live swarm (epoch ms); a finished swarm ends at its last event. */
21
+ now?: number | undefined;
22
+ }
23
+
24
+ const SWARM_LANE = "swarm";
25
+
26
+ /** The parent lifecycle actions that mark a horde's progress; member-level churn stays on the run graph. */
27
+ const PHASE_ACTIONS = new Set([
28
+ "run-start", "phase-start", "phase-end", "stop-clean", "stop-hard", "solved-validated", "solved-banked",
29
+ "solved-contested", "acceptance", "run-end",
30
+ ]);
31
+
32
+ function str(value: unknown): string | undefined {
33
+ return typeof value === "string" ? value : undefined;
34
+ }
35
+
36
+ function labelOf(event: HordeEvent): [string, string] {
37
+ const p = event.payload;
38
+ switch (event.action) {
39
+ case "run-start": return ["start", `${String(p.count ?? "?")} members${str(p.slice) ? `; workstream: ${oneLine(str(p.slice) ?? "", 160)}` : ""}`];
40
+ case "phase-start": return [`${str(p.phase) ?? "phase"} start`, `${str(p.phase) ?? "phase"} started`];
41
+ case "phase-end": return [`${str(p.phase) ?? "phase"} end`, `${str(p.phase) ?? "phase"} ended`];
42
+ case "stop-clean": return [`stop-clean (${str(p.label) ?? "stop-clean"})`, "clean stop requested"];
43
+ case "stop-hard": return [`stop-hard (${str(p.by) ?? "user"})`, "members terminated"];
44
+ case "solved-validated": return ["solved validated", str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
45
+ case "solved-banked": return [`solved banked (round ${String(p.round ?? "?")})`, "the run continues until stopped"];
46
+ case "solved-contested": return ["solved contested", `by ${str(p.member) ?? ""}`];
47
+ case "acceptance": return [`acceptance ${str(p.status) ?? ""}`.trim(), `${String(p.issues ?? 0)} issues`];
48
+ case "run-end": return ["end", `outcome ${str(p.outcome) ?? "unknown"}`];
49
+ default: return [event.action, ""];
50
+ }
51
+ }
52
+
53
+ function hordeNodes(horde: SwarmGraphHorde): GraphNode[] {
54
+ return horde.events
55
+ .filter((event) => event.attempt === LIFECYCLE_ATTEMPT && event.outcome === "applied" && PHASE_ACTIONS.has(event.action))
56
+ .map((event) => {
57
+ const [label, title] = labelOf(event);
58
+ const at = Date.parse(event.at);
59
+ return {
60
+ id: `${horde.label}:${event.eventId}`,
61
+ kind: "lifecycle" as const,
62
+ action: event.action,
63
+ label,
64
+ title: `${horde.label} ${event.eventId} ${title}`.trim(),
65
+ lane: horde.label,
66
+ at: Number.isFinite(at) ? at : 0,
67
+ eventId: event.eventId,
68
+ href: eventHref(horde.runId, event.seq),
69
+ outcome: "applied" as const,
70
+ marker: true,
71
+ placeholder: false,
72
+ };
73
+ })
74
+ .filter((node) => node.at > 0);
75
+ }
76
+
77
+ function mergeNodes(input: SwarmGraphInput): GraphNode[] {
78
+ const merge = input.merge;
79
+ if (!merge) return [];
80
+ const node = (id: string, label: string, title: string, at: number): GraphNode => ({
81
+ id, kind: "lifecycle", action: id, label, title, lane: SWARM_LANE, at, eventId: "",
82
+ href: `/swarms/${encodeURIComponent(input.swarmId)}`, outcome: "applied", marker: true, placeholder: false,
83
+ });
84
+ const nodes = [node("merge-start", "merge start", "merging the hordes' deliverables", merge.startedAt)];
85
+ if (merge.endedAt !== undefined) {
86
+ nodes.push(node("merge-end", `merge end${merge.written === false ? " (no deliverable)" : ""}`,
87
+ merge.written === false ? "the swarm deliverable was not written" : "DELIVERABLE.md written", merge.endedAt));
88
+ }
89
+ return nodes;
90
+ }
91
+
92
+ /** One lane per horde plus the swarm lane, so the hordes' phases line up against the merge step. */
93
+ export function buildSwarmGraph(input: SwarmGraphInput): RunGraph {
94
+ const perHorde = input.hordes.map((horde) => hordeNodes(horde));
95
+ const nodes = [...perHorde.flat(), ...mergeNodes(input)].sort((a, b) => a.at - b.at);
96
+ const edges: GraphEdge[] = [];
97
+ if (input.merge) {
98
+ for (const horde of perHorde) {
99
+ const end = horde.find((node) => node.action === "run-end");
100
+ if (end) edges.push({ from: end.id, to: "merge-start", label: "merged", tone: "lifecycle" });
101
+ }
102
+ }
103
+ const lanes: GraphLane[] = [
104
+ { id: SWARM_LANE, label: SWARM_LANE },
105
+ ...input.hordes.map((horde) => ({
106
+ id: horde.label,
107
+ label: `${horde.label} ${horde.runId}`,
108
+ href: `/runs/${encodeURIComponent(horde.runId)}`,
109
+ status: horde.status,
110
+ })),
111
+ ];
112
+ const start = input.startedAt;
113
+ const latest = Math.max(input.now ?? 0, input.merge?.endedAt ?? 0, ...nodes.map((node) => node.at));
114
+ const end = latest > start ? latest : start + 1_000;
115
+ const hordeTimes = perHorde.flat().map((node) => node.at);
116
+ const live = input.now !== undefined && !input.hordes.every((horde) => horde.events.some((event) => event.action === "run-end"));
117
+ return {
118
+ runId: input.swarmId,
119
+ start,
120
+ end,
121
+ membersEnd: live || hordeTimes.length === 0 ? end : Math.min(end, Math.max(start, ...hordeTimes)),
122
+ lanes,
123
+ phases: [],
124
+ nodes,
125
+ edges,
126
+ };
127
+ }
@@ -2,13 +2,19 @@ import { postBoard } from "../ledger/board.js";
2
2
  import { assertClaim, confirmClaim, contestClaim, listClaims, retractClaim, supersedeClaim } from "../ledger/claims.js";
3
3
  import type { ClaimEntry } from "../ledger/claims.js";
4
4
  import { listConcerns } from "../ledger/concerns.js";
5
- import { addFact, listFacts } from "../ledger/facts.js";
6
- import { addFinding, findingSummaries, verifyFinding } from "../ledger/findings.js";
5
+ import { addFact, factUnsettled, listFacts, unsettledFactCitations } from "../ledger/facts.js";
6
+ import type { FactEntry } from "../ledger/facts.js";
7
+ import * as path from "node:path";
8
+ import { CLUSTER_THRESHOLD } from "../ledger/clusters.js";
9
+ import { collaborationState } from "../ledger/collaboration.js";
10
+ import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerCitations, verifyFinding } from "../ledger/findings.js";
11
+ import { textOverlap } from "../ledger/text-match.js";
7
12
  import { addIdea, claimIdea, dropIdea, finishIdea, listIdeas, ruleOutIdea } from "../ledger/ideas.js";
8
13
  import type { Idea } from "../ledger/ideas.js";
9
14
  import { claimResource, listResources } from "../ledger/resources.js";
10
- import { cleanStopRequestedFor, confirmSolved, contestSolved, markSolved } from "../ledger/run-signals.js";
15
+ import { cleanStopRequestedFor, confirmSolved, contestSolved, markSolved, readSolved } from "../ledger/run-signals.js";
11
16
  import { oneLine } from "../format.js";
17
+ import type { SiblingHorde } from "../member.js";
12
18
  import { validateFindingResourceCitations } from "../citations.js";
13
19
  import { idleHint } from "./hints.js";
14
20
  import type { HordeToolParams } from "./params.js";
@@ -34,6 +40,10 @@ interface ActionResult {
34
40
  noop?: boolean;
35
41
  /** For confirm-solved: whether this confirmation validated the proposal or joined an earlier one. */
36
42
  confirmation?: "credited" | "additional";
43
+ /** For amend: the revisions the edit moved between, and whether the claim line changed; reattest reads them. */
44
+ previousRevision?: string;
45
+ revision?: string;
46
+ titleChanged?: boolean;
37
47
  }
38
48
 
39
49
  interface ActionContext {
@@ -43,6 +53,35 @@ interface ActionContext {
43
53
  runId: string | undefined;
44
54
  /** True when a clean stop is pending for this member. */
45
55
  cleanStop: boolean;
56
+ /** The member's role instruction, when one was assigned. */
57
+ role: string | undefined;
58
+ /** Other hordes of the swarm whose ledgers the read actions include, tagged by label. */
59
+ siblings: SiblingHorde[];
60
+ }
61
+
62
+ /**
63
+ * Shared read, local write: the read actions append each sibling horde's entries, tagged `[hN]`,
64
+ * so a fact, a rule-out or a resource another horde recorded is not re-derived here. A sibling
65
+ * whose ledger cannot be read is reported, not fatal.
66
+ */
67
+ function siblingSections(siblings: SiblingHorde[], what: string, lines: (hordeDir: string) => string[]): string {
68
+ return siblings
69
+ .map((sibling) => {
70
+ const tag = `[${sibling.label}]`;
71
+ let entries: string[];
72
+ try {
73
+ entries = lines(sibling.hordeDir).map((line) => `${tag} ${line}`);
74
+ } catch (error) {
75
+ entries = [`${tag} unreadable: ${oneLine(error instanceof Error ? error.message : String(error), 120)}`];
76
+ }
77
+ const head = `${tag} ${what} of horde ${sibling.label} (read-only; ${sibling.hordeDir}):`;
78
+ return `\n\n${head}\n${entries.length > 0 ? entries.join("\n") : `${tag} none`}`;
79
+ })
80
+ .join("");
81
+ }
82
+
83
+ export function isVerifierRole(role: string | undefined): boolean {
84
+ return /verifier/i.test(role ?? "");
46
85
  }
47
86
 
48
87
  type ActionHandler = (ctx: ActionContext) => ActionResult | Promise<ActionResult>;
@@ -93,18 +132,19 @@ function closedIdea(ctx: ActionContext, idea: Idea, text: string): ActionResult
93
132
  return { text: `${text}${hint}`, id: idea.id };
94
133
  }
95
134
 
96
- function listAction({ hordeDir }: ActionContext): ActionResult {
135
+ function ideaLine(idea: Idea): string {
136
+ const owner = idea.owner ? ` @${idea.owner}` : "";
137
+ const note = idea.note ? `: ${idea.note}` : "";
138
+ return `#${idea.id} [${idea.mark}]${owner} ${idea.text}${note}`;
139
+ }
140
+
141
+ function listAction({ hordeDir, siblings }: ActionContext): ActionResult {
97
142
  const ideas = listIdeas(hordeDir);
98
- if (ideas.length === 0) return { text: "IDEAS.md has no ideas yet" };
99
- return {
100
- text: ideas
101
- .map((idea) => {
102
- const owner = idea.owner ? ` @${idea.owner}` : "";
103
- const note = idea.note ? `: ${idea.note}` : "";
104
- return `#${idea.id} [${idea.mark}]${owner} ${idea.text}${note}`;
105
- })
106
- .join("\n"),
107
- };
143
+ const own = ideas.length === 0 ? "IDEAS.md has no ideas yet" : ideas.map(ideaLine).join("\n");
144
+ // Only closed ideas cross hordes: results prevent re-derivation, open ones would invite claims.
145
+ const shared = siblingSections(siblings, "done and ruled-out ideas", (dir) =>
146
+ listIdeas(dir).filter((idea) => idea.mark === "x" || idea.mark === "!").map(ideaLine));
147
+ return { text: `${own}${shared}` };
108
148
  }
109
149
 
110
150
  async function postAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
@@ -177,7 +217,23 @@ async function findingAction(ctx: ActionContext): Promise<ActionResult> {
177
217
  };
178
218
  }
179
219
  const advisory = concernAdvisory ? `\nNote: ${concernAdvisory}` : "";
180
- return { text: `Recorded finding F${finding.id} [${concern}]: ${finding.title}${advisory}`, id: finding.id };
220
+ return { text: `Recorded finding F${finding.id} [${concern}]: ${finding.title}${advisory}${unregisteredHarness(ctx, text)}`, id: finding.id };
221
+ }
222
+
223
+ const HARNESS_PATH = /\bartifacts\/[\w./-]+\.(?:py|js|mjs|cjs|ts|sh|go|rs|rb|pl)\b/g;
224
+
225
+ /**
226
+ * A driver or harness every member rewrote because nobody registered it: a runnable under
227
+ * artifacts/ that no resource note and no published artifact names is pointed at `resource`.
228
+ */
229
+ function unregisteredHarness(ctx: ActionContext, text: string): string {
230
+ const paths = [...new Set(text.match(HARNESS_PATH) ?? [])];
231
+ if (!paths.length) return "";
232
+ const known = listResources(ctx.hordeDir).map((resource) => `${resource.name} ${resource.note ?? ""}`).join("\n");
233
+ const published = ctx.runId ? collaborationState(ctx.hordeDir, ctx.runId).artifacts.map((artifact) => artifact.path) : [];
234
+ const unregistered = paths.filter((file) => !known.includes(file) && !published.some((artifact) => file.endsWith(artifact)));
235
+ if (!unregistered.length) return "";
236
+ return `\nNote: ${unregistered.join(", ")} looks like a harness no R# covers. Claim it with 'horde resource' (name in text, path in note) so other members reuse it instead of writing their own.`;
181
237
  }
182
238
 
183
239
  function concernsAction({ hordeDir }: ActionContext): ActionResult {
@@ -209,6 +265,26 @@ async function verifyAction(ctx: ActionContext): Promise<ActionResult> {
209
265
  };
210
266
  }
211
267
 
268
+ async function amendAction(ctx: ActionContext): Promise<ActionResult> {
269
+ const { hordeDir, member, params, runId } = ctx;
270
+ validateFindingResourceCitations(hordeDir, runId, params.text ?? "");
271
+ const { finding, previous, titleChanged } = await amendFinding(hordeDir, requireRun(ctx), member, {
272
+ id: requireId(params),
273
+ text: params.text,
274
+ note: params.note,
275
+ });
276
+ return {
277
+ text:
278
+ `Amended F${finding.id}: ${finding.title}. Revision ${previous.slice(0, 12)} is superseded by the new text; ` +
279
+ "reviews stay pinned to the revision they checked. " +
280
+ (titleChanged ? "The claim line changed, so reviewers must review again." : "Ask reviewers to reattest if what they checked is unchanged, or to review again."),
281
+ id: finding.id,
282
+ previousRevision: previous,
283
+ revision: findingRevision(finding),
284
+ titleChanged,
285
+ };
286
+ }
287
+
212
288
  async function factAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
213
289
  const { fact, duplicateOf, corroborationFull } = await addFact(
214
290
  hordeDir,
@@ -225,13 +301,30 @@ async function factAction({ hordeDir, member, params }: ActionContext): Promise<
225
301
  id: fact.id,
226
302
  };
227
303
  }
228
- return { text: `Recorded fact T${fact.id}: ${fact.text}`, id: fact.id };
304
+ const provisional = fact.provisionalOn
305
+ ? `\nT${fact.id} is provisional: ${fact.provisionalOn} is still auditing its subject. The answer may not quote it as settled until ${fact.provisionalOn} is closed or resolved; cite that closure with it.`
306
+ : "";
307
+ return { text: `Recorded fact T${fact.id}: ${fact.text}${provisional}`, id: fact.id };
229
308
  }
230
309
 
231
- function factsAction({ hordeDir }: ActionContext): ActionResult {
232
- const facts = listFacts(hordeDir);
233
- if (facts.length === 0) return { text: "FACTS.md has no facts yet" };
234
- return { text: facts.map((fact) => `T${fact.id} ${fact.text} @${fact.author}`).join("\n") };
310
+ function factLines(hordeDir: string): string[] {
311
+ const status = (fact: FactEntry): string => {
312
+ if (!fact.provisionalOn) return "";
313
+ return factUnsettled(hordeDir, fact) ? ` [provisional: ${fact.provisionalOn} open]` : ` [was provisional on ${fact.provisionalOn}, now closed]`;
314
+ };
315
+ return listFacts(hordeDir).map((fact) => `T${fact.id} ${fact.text} @${fact.author}${status(fact)}`);
316
+ }
317
+
318
+ function factsAction({ hordeDir, siblings }: ActionContext): ActionResult {
319
+ const own = factLines(hordeDir);
320
+ const shared = siblingSections(siblings, "facts", factLines);
321
+ return { text: `${own.length === 0 ? "FACTS.md has no facts yet" : own.join("\n")}${shared}` };
322
+ }
323
+
324
+ /** An answer that quotes a provisional fact as settled asserts what an open idea is still auditing. */
325
+ function unsettledAnswerFacts(ctx: ActionContext, text: string): string | undefined {
326
+ const unsettled = unsettledFactCitations(ctx.hordeDir, text);
327
+ return unsettled.length ? `quotes provisional ${unsettled.join(", ")}` : undefined;
235
328
  }
236
329
 
237
330
  async function assertAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
@@ -288,13 +381,44 @@ async function ideaAction({ hordeDir, member, params }: ActionContext): Promise<
288
381
  return { text: `Added idea #${idea.id}: ${idea.text}`, id: idea.id };
289
382
  }
290
383
 
291
- async function claimAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
384
+ /**
385
+ * The verifier reviews before it investigates: a verifier that claimed ideas and filed the
386
+ * run's main findings left its first session with zero scoped reviews while its summary
387
+ * reported verification complete. Any verdict counts; a finding it cannot reproduce gets a
388
+ * refuting or inconclusive review, not silence.
389
+ */
390
+ function checkVerifierClaim(ctx: ActionContext): void {
391
+ if (!ctx.runId || !isVerifierRole(ctx.role)) return;
392
+ const pending = pendingReviewsForMember(ctx.hordeDir, ctx.runId, ctx.member);
393
+ if (!pending.length) return;
394
+ throw new Error(
395
+ `horde: you are the verifier and ${pending.length} finding(s) by other members await your review: ` +
396
+ `${pending.map((f) => `F${f.id}`).join(", ")}. Review each (any verdict, with its unchecked scope) before claiming an idea`,
397
+ );
398
+ }
399
+
400
+ /** Findings of this run that already have an independent corroborator or a full review and overlap the idea. */
401
+ function reproducedOverlap(ctx: ActionContext, idea: Idea): string {
402
+ if (!ctx.runId) return "";
403
+ const state = collaborationState(ctx.hordeDir, ctx.runId);
404
+ const settled = listFindingsForRun(ctx.hordeDir, ctx.runId).filter((f) =>
405
+ f.supersededBy === undefined && (f.corroborators.length > 0 || findingReviewState(f, state.reviews, state.messages).status === "full"));
406
+ const match = textOverlap(settled.map((f) => f.title), idea.text);
407
+ if (!match || Math.max(match.jaccard, match.overlap) < CLUSTER_THRESHOLD) return "";
408
+ const finding = settled[match.index];
409
+ if (!finding) return "";
410
+ return `\nNote: F${finding.id} ("${finding.title}") is already independently reproduced; a further reproduction adds nothing. Attach a new vector or distinct evidence with deltaOf: ${finding.id}, or take a different slice.`;
411
+ }
412
+
413
+ async function claimAction(ctx: ActionContext): Promise<ActionResult> {
414
+ const { hordeDir, member, params } = ctx;
415
+ checkVerifierClaim(ctx);
292
416
  const { idea, overlap } = await claimIdea(hordeDir, member, requireId(params));
293
417
  const warning = overlap
294
418
  ? `\nWarning: #${overlap.id} held by ${overlap.owner} covers a similar slice ("${overlap.text}"). ` +
295
419
  `Coordinate: take a distinct angle, or attach evidence to #${overlap.id} with a finding or contest.`
296
420
  : "";
297
- return { text: `Claimed #${idea.id}: ${idea.text}${warning}`, id: idea.id };
421
+ return { text: `Claimed #${idea.id}: ${idea.text}${warning}${reproducedOverlap(ctx, idea)}`, id: idea.id };
298
422
  }
299
423
 
300
424
  async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
@@ -326,27 +450,46 @@ async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
326
450
  };
327
451
  }
328
452
 
329
- function resourcesAction({ hordeDir }: ActionContext): ActionResult {
330
- const resources = listResources(hordeDir);
331
- if (resources.length === 0) return { text: "RESOURCES.md has no resources yet" };
332
- return {
333
- text: resources
334
- .map(
335
- (resource) =>
336
- `R${resource.id} ${resource.name} @${resource.owner}` +
337
- `${resource.note ? `: ${resource.note}` : ""}` +
338
- `${resource.reusers.length > 0 ? ` reuse=${resource.reusers.join(",")}` : ""}`,
339
- )
340
- .join("\n"),
341
- };
453
+ function resourceLines(hordeDir: string): string[] {
454
+ return listResources(hordeDir).map(
455
+ (resource) =>
456
+ `R${resource.id} ${resource.name} @${resource.owner}` +
457
+ `${resource.note ? `: ${resource.note}` : ""}` +
458
+ `${resource.reusers.length > 0 ? ` reuse=${resource.reusers.join(",")}` : ""}`,
459
+ );
460
+ }
461
+
462
+ function resourcesAction({ hordeDir, siblings }: ActionContext): ActionResult {
463
+ const own = resourceLines(hordeDir);
464
+ const shared = siblingSections(siblings, "resources (reuse by path; cite the path, not the R#)", resourceLines);
465
+ return { text: `${own.length === 0 ? "RESOURCES.md has no resources yet" : own.join("\n")}${shared}` };
466
+ }
467
+
468
+ /**
469
+ * An answer that cites a superseded finding, or one amended since the proposal pinned it, ships a
470
+ * stale reading as the run's result. The proposer cites the current text instead; a validator is
471
+ * refused until the proposer re-proposes.
472
+ */
473
+ function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
474
+ const runId = requireRun(ctx);
475
+ const stale = staleAnswerCitations(listFindingsForRun(ctx.hordeDir, runId), text, cites);
476
+ if (!stale.length) return undefined;
477
+ return `cites ${stale.join(", ")}`;
342
478
  }
343
479
 
344
480
  async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
481
+ const runId = requireRun(ctx);
482
+ const text = ctx.params.text ?? "";
483
+ const stale = staleAnswer(ctx, text);
484
+ if (stale) throw new Error(`horde: the answer ${stale}; cite the current findings and propose again`);
485
+ const unsettled = unsettledAnswerFacts(ctx, text);
486
+ if (unsettled) throw new Error(`horde: the answer ${unsettled}; close the idea or resolve the claim first, or leave the fact out`);
345
487
  const { signal, alreadySolved } = await markSolved(
346
488
  ctx.hordeDir,
347
- requireRun(ctx),
489
+ runId,
348
490
  ctx.member,
349
491
  ctx.params.text,
492
+ answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
350
493
  );
351
494
  return {
352
495
  text: alreadySolved
@@ -356,7 +499,13 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
356
499
  }
357
500
 
358
501
  async function confirmSolvedAction(ctx: ActionContext): Promise<ActionResult> {
359
- const answer = await confirmSolved(ctx.hordeDir, requireRun(ctx), ctx.member, ctx.params.text, ctx.params.revision);
502
+ const runId = requireRun(ctx);
503
+ const proposal = readSolved(path.join(ctx.hordeDir, "runs", runId));
504
+ const stale = proposal ? staleAnswer(ctx, proposal.text, proposal.cites) : undefined;
505
+ if (stale) throw new Error(`horde: the pending answer ${stale}; it cannot be validated until ${proposal?.member} re-proposes against the current findings`);
506
+ const unsettled = proposal ? unsettledAnswerFacts(ctx, proposal.text) : undefined;
507
+ if (unsettled) throw new Error(`horde: the pending answer ${unsettled}; close the idea or resolve the claim before validating it`);
508
+ const answer = await confirmSolved(ctx.hordeDir, runId, ctx.member, ctx.params.text, ctx.params.revision);
360
509
  if (!answer.ok) throw new Error(`horde: ${answer.reason}`);
361
510
  if (answer.reason !== "validated") {
362
511
  return {
@@ -378,9 +527,23 @@ async function contestSolvedAction(ctx: ActionContext): Promise<ActionResult> {
378
527
  };
379
528
  }
380
529
 
530
+ /** A result line that reports a confirmed negative: the lead is dead, not merely finished. */
531
+ const NEGATIVE_RESULT =
532
+ /\b(?:not (?:exploitable|vulnerable|reachable|reproducible|affected)|does not reproduce|could not reproduce|cannot be reproduced|confirmed negative|negative result|dead end|ruled? out|no (?:vulnerability|exploit|issue|bug|weakness) (?:found|present|exists))\b/i;
533
+
381
534
  async function doneAction(ctx: ActionContext): Promise<ActionResult> {
382
535
  const { hordeDir, member, params } = ctx;
383
- const idea = await finishIdea(hordeDir, member, requireId(params), closeNote(params));
536
+ const note = closeNote(params) ?? "";
537
+ // Four confirmed negatives were recorded only as done notes and prose, so the next run on the
538
+ // same target re-walked them. A negative closes with rule-out, which later runs can read.
539
+ const negative = NEGATIVE_RESULT.exec(note);
540
+ if (negative) {
541
+ throw new Error(
542
+ `horde: "${negative[0]}" reads as a confirmed negative; close #${requireId(params)} with 'horde rule-out' and the same note so later runs do not retry it. ` +
543
+ "Use done only for work that produced a result.",
544
+ );
545
+ }
546
+ const idea = await finishIdea(hordeDir, member, requireId(params), note || undefined);
384
547
  return closedIdea(ctx, idea, `Done #${idea.id}: ${idea.text}`);
385
548
  }
386
549
 
@@ -422,6 +585,7 @@ const HANDLERS: Partial<Record<HordeToolParams["action"], ActionHandler>> = {
422
585
  "contest-solved": contestSolvedAction,
423
586
  concerns: concernsAction,
424
587
  verify: verifyAction,
588
+ amend: amendAction,
425
589
  };
426
590
 
427
591
  export async function runHordeAction(
@@ -429,10 +593,11 @@ export async function runHordeAction(
429
593
  member: string,
430
594
  params: HordeToolParams,
431
595
  runId: string | undefined = process.env.PI_HORDE_RUN,
432
- tracking: { attempt?: string; requestId?: string } = {},
596
+ tracking: { attempt?: string; requestId?: string; role?: string | undefined; siblings?: SiblingHorde[] | undefined } = {},
433
597
  ): Promise<ActionResult> {
434
598
  const cleanStop = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));
435
- const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...tracking, requestId: params.requestId ?? tracking.requestId };
599
+ const { role, siblings = [], ...eventTracking } = tracking;
600
+ const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...eventTracking, requestId: params.requestId ?? tracking.requestId };
436
601
  if (COLLABORATION_ACTIONS.has(params.action)) {
437
602
  if (!runId) throw new Error("horde: collaboration needs an active run");
438
603
  return runCollaborationAction(ctx, params);
@@ -444,7 +609,7 @@ export async function runHordeAction(
444
609
  const intent = audit ? await recordEvent({ ...ctx, requestId: ctx.requestId ? `${ctx.requestId}:intent` : undefined }, params.action, { outcome: "intent", payload: { input: params } }) : undefined;
445
610
  try {
446
611
  const refused = cleanStop && NEW_WORK_ACTIONS.has(params.action);
447
- const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop });
612
+ const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop, role, siblings });
448
613
  // A refusal and a no-op both record `noop`; the no-op keeps `result.noop` so the graph and the
449
614
  // report can tell an absorbed duplicate from refused work.
450
615
  if (audit) await recordEvent(ctx, params.action, { outcome: refused || result.noop ? "noop" : "applied", payload: { input: params, result }, parents: intent ? [intent.eventId] : [] });