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.
@@ -2,12 +2,17 @@ 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";
12
17
  import { validateFindingResourceCitations } from "../citations.js";
13
18
  import { idleHint } from "./hints.js";
@@ -34,6 +39,10 @@ interface ActionResult {
34
39
  noop?: boolean;
35
40
  /** For confirm-solved: whether this confirmation validated the proposal or joined an earlier one. */
36
41
  confirmation?: "credited" | "additional";
42
+ /** For amend: the revisions the edit moved between, and whether the claim line changed; reattest reads them. */
43
+ previousRevision?: string;
44
+ revision?: string;
45
+ titleChanged?: boolean;
37
46
  }
38
47
 
39
48
  interface ActionContext {
@@ -43,6 +52,12 @@ interface ActionContext {
43
52
  runId: string | undefined;
44
53
  /** True when a clean stop is pending for this member. */
45
54
  cleanStop: boolean;
55
+ /** The member's role instruction, when one was assigned. */
56
+ role: string | undefined;
57
+ }
58
+
59
+ export function isVerifierRole(role: string | undefined): boolean {
60
+ return /verifier/i.test(role ?? "");
46
61
  }
47
62
 
48
63
  type ActionHandler = (ctx: ActionContext) => ActionResult | Promise<ActionResult>;
@@ -177,7 +192,23 @@ async function findingAction(ctx: ActionContext): Promise<ActionResult> {
177
192
  };
178
193
  }
179
194
  const advisory = concernAdvisory ? `\nNote: ${concernAdvisory}` : "";
180
- return { text: `Recorded finding F${finding.id} [${concern}]: ${finding.title}${advisory}`, id: finding.id };
195
+ return { text: `Recorded finding F${finding.id} [${concern}]: ${finding.title}${advisory}${unregisteredHarness(ctx, text)}`, id: finding.id };
196
+ }
197
+
198
+ const HARNESS_PATH = /\bartifacts\/[\w./-]+\.(?:py|js|mjs|cjs|ts|sh|go|rs|rb|pl)\b/g;
199
+
200
+ /**
201
+ * A driver or harness every member rewrote because nobody registered it: a runnable under
202
+ * artifacts/ that no resource note and no published artifact names is pointed at `resource`.
203
+ */
204
+ function unregisteredHarness(ctx: ActionContext, text: string): string {
205
+ const paths = [...new Set(text.match(HARNESS_PATH) ?? [])];
206
+ if (!paths.length) return "";
207
+ const known = listResources(ctx.hordeDir).map((resource) => `${resource.name} ${resource.note ?? ""}`).join("\n");
208
+ const published = ctx.runId ? collaborationState(ctx.hordeDir, ctx.runId).artifacts.map((artifact) => artifact.path) : [];
209
+ const unregistered = paths.filter((file) => !known.includes(file) && !published.some((artifact) => file.endsWith(artifact)));
210
+ if (!unregistered.length) return "";
211
+ 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
212
  }
182
213
 
183
214
  function concernsAction({ hordeDir }: ActionContext): ActionResult {
@@ -209,6 +240,26 @@ async function verifyAction(ctx: ActionContext): Promise<ActionResult> {
209
240
  };
210
241
  }
211
242
 
243
+ async function amendAction(ctx: ActionContext): Promise<ActionResult> {
244
+ const { hordeDir, member, params, runId } = ctx;
245
+ validateFindingResourceCitations(hordeDir, runId, params.text ?? "");
246
+ const { finding, previous, titleChanged } = await amendFinding(hordeDir, requireRun(ctx), member, {
247
+ id: requireId(params),
248
+ text: params.text,
249
+ note: params.note,
250
+ });
251
+ return {
252
+ text:
253
+ `Amended F${finding.id}: ${finding.title}. Revision ${previous.slice(0, 12)} is superseded by the new text; ` +
254
+ "reviews stay pinned to the revision they checked. " +
255
+ (titleChanged ? "The claim line changed, so reviewers must review again." : "Ask reviewers to reattest if what they checked is unchanged, or to review again."),
256
+ id: finding.id,
257
+ previousRevision: previous,
258
+ revision: findingRevision(finding),
259
+ titleChanged,
260
+ };
261
+ }
262
+
212
263
  async function factAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
213
264
  const { fact, duplicateOf, corroborationFull } = await addFact(
214
265
  hordeDir,
@@ -225,13 +276,26 @@ async function factAction({ hordeDir, member, params }: ActionContext): Promise<
225
276
  id: fact.id,
226
277
  };
227
278
  }
228
- return { text: `Recorded fact T${fact.id}: ${fact.text}`, id: fact.id };
279
+ const provisional = fact.provisionalOn
280
+ ? `\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.`
281
+ : "";
282
+ return { text: `Recorded fact T${fact.id}: ${fact.text}${provisional}`, id: fact.id };
229
283
  }
230
284
 
231
285
  function factsAction({ hordeDir }: ActionContext): ActionResult {
232
286
  const facts = listFacts(hordeDir);
233
287
  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") };
288
+ const status = (fact: FactEntry): string => {
289
+ if (!fact.provisionalOn) return "";
290
+ return factUnsettled(hordeDir, fact) ? ` [provisional: ${fact.provisionalOn} open]` : ` [was provisional on ${fact.provisionalOn}, now closed]`;
291
+ };
292
+ return { text: facts.map((fact) => `T${fact.id} ${fact.text} @${fact.author}${status(fact)}`).join("\n") };
293
+ }
294
+
295
+ /** An answer that quotes a provisional fact as settled asserts what an open idea is still auditing. */
296
+ function unsettledAnswerFacts(ctx: ActionContext, text: string): string | undefined {
297
+ const unsettled = unsettledFactCitations(ctx.hordeDir, text);
298
+ return unsettled.length ? `quotes provisional ${unsettled.join(", ")}` : undefined;
235
299
  }
236
300
 
237
301
  async function assertAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
@@ -288,13 +352,44 @@ async function ideaAction({ hordeDir, member, params }: ActionContext): Promise<
288
352
  return { text: `Added idea #${idea.id}: ${idea.text}`, id: idea.id };
289
353
  }
290
354
 
291
- async function claimAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
355
+ /**
356
+ * The verifier reviews before it investigates: a verifier that claimed ideas and filed the
357
+ * run's main findings left its first session with zero scoped reviews while its summary
358
+ * reported verification complete. Any verdict counts; a finding it cannot reproduce gets a
359
+ * refuting or inconclusive review, not silence.
360
+ */
361
+ function checkVerifierClaim(ctx: ActionContext): void {
362
+ if (!ctx.runId || !isVerifierRole(ctx.role)) return;
363
+ const pending = pendingReviewsForMember(ctx.hordeDir, ctx.runId, ctx.member);
364
+ if (!pending.length) return;
365
+ throw new Error(
366
+ `horde: you are the verifier and ${pending.length} finding(s) by other members await your review: ` +
367
+ `${pending.map((f) => `F${f.id}`).join(", ")}. Review each (any verdict, with its unchecked scope) before claiming an idea`,
368
+ );
369
+ }
370
+
371
+ /** Findings of this run that already have an independent corroborator or a full review and overlap the idea. */
372
+ function reproducedOverlap(ctx: ActionContext, idea: Idea): string {
373
+ if (!ctx.runId) return "";
374
+ const state = collaborationState(ctx.hordeDir, ctx.runId);
375
+ const settled = listFindingsForRun(ctx.hordeDir, ctx.runId).filter((f) =>
376
+ f.supersededBy === undefined && (f.corroborators.length > 0 || findingReviewState(f, state.reviews, state.messages).status === "full"));
377
+ const match = textOverlap(settled.map((f) => f.title), idea.text);
378
+ if (!match || Math.max(match.jaccard, match.overlap) < CLUSTER_THRESHOLD) return "";
379
+ const finding = settled[match.index];
380
+ if (!finding) return "";
381
+ 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.`;
382
+ }
383
+
384
+ async function claimAction(ctx: ActionContext): Promise<ActionResult> {
385
+ const { hordeDir, member, params } = ctx;
386
+ checkVerifierClaim(ctx);
292
387
  const { idea, overlap } = await claimIdea(hordeDir, member, requireId(params));
293
388
  const warning = overlap
294
389
  ? `\nWarning: #${overlap.id} held by ${overlap.owner} covers a similar slice ("${overlap.text}"). ` +
295
390
  `Coordinate: take a distinct angle, or attach evidence to #${overlap.id} with a finding or contest.`
296
391
  : "";
297
- return { text: `Claimed #${idea.id}: ${idea.text}${warning}`, id: idea.id };
392
+ return { text: `Claimed #${idea.id}: ${idea.text}${warning}${reproducedOverlap(ctx, idea)}`, id: idea.id };
298
393
  }
299
394
 
300
395
  async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
@@ -341,12 +436,31 @@ function resourcesAction({ hordeDir }: ActionContext): ActionResult {
341
436
  };
342
437
  }
343
438
 
439
+ /**
440
+ * An answer that cites a superseded finding, or one amended since the proposal pinned it, ships a
441
+ * stale reading as the run's result. The proposer cites the current text instead; a validator is
442
+ * refused until the proposer re-proposes.
443
+ */
444
+ function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
445
+ const runId = requireRun(ctx);
446
+ const stale = staleAnswerCitations(listFindingsForRun(ctx.hordeDir, runId), text, cites);
447
+ if (!stale.length) return undefined;
448
+ return `cites ${stale.join(", ")}`;
449
+ }
450
+
344
451
  async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
452
+ const runId = requireRun(ctx);
453
+ const text = ctx.params.text ?? "";
454
+ const stale = staleAnswer(ctx, text);
455
+ if (stale) throw new Error(`horde: the answer ${stale}; cite the current findings and propose again`);
456
+ const unsettled = unsettledAnswerFacts(ctx, text);
457
+ if (unsettled) throw new Error(`horde: the answer ${unsettled}; close the idea or resolve the claim first, or leave the fact out`);
345
458
  const { signal, alreadySolved } = await markSolved(
346
459
  ctx.hordeDir,
347
- requireRun(ctx),
460
+ runId,
348
461
  ctx.member,
349
462
  ctx.params.text,
463
+ answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
350
464
  );
351
465
  return {
352
466
  text: alreadySolved
@@ -356,7 +470,13 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
356
470
  }
357
471
 
358
472
  async function confirmSolvedAction(ctx: ActionContext): Promise<ActionResult> {
359
- const answer = await confirmSolved(ctx.hordeDir, requireRun(ctx), ctx.member, ctx.params.text, ctx.params.revision);
473
+ const runId = requireRun(ctx);
474
+ const proposal = readSolved(path.join(ctx.hordeDir, "runs", runId));
475
+ const stale = proposal ? staleAnswer(ctx, proposal.text, proposal.cites) : undefined;
476
+ if (stale) throw new Error(`horde: the pending answer ${stale}; it cannot be validated until ${proposal?.member} re-proposes against the current findings`);
477
+ const unsettled = proposal ? unsettledAnswerFacts(ctx, proposal.text) : undefined;
478
+ if (unsettled) throw new Error(`horde: the pending answer ${unsettled}; close the idea or resolve the claim before validating it`);
479
+ const answer = await confirmSolved(ctx.hordeDir, runId, ctx.member, ctx.params.text, ctx.params.revision);
360
480
  if (!answer.ok) throw new Error(`horde: ${answer.reason}`);
361
481
  if (answer.reason !== "validated") {
362
482
  return {
@@ -378,9 +498,23 @@ async function contestSolvedAction(ctx: ActionContext): Promise<ActionResult> {
378
498
  };
379
499
  }
380
500
 
501
+ /** A result line that reports a confirmed negative: the lead is dead, not merely finished. */
502
+ const NEGATIVE_RESULT =
503
+ /\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;
504
+
381
505
  async function doneAction(ctx: ActionContext): Promise<ActionResult> {
382
506
  const { hordeDir, member, params } = ctx;
383
- const idea = await finishIdea(hordeDir, member, requireId(params), closeNote(params));
507
+ const note = closeNote(params) ?? "";
508
+ // Four confirmed negatives were recorded only as done notes and prose, so the next run on the
509
+ // same target re-walked them. A negative closes with rule-out, which later runs can read.
510
+ const negative = NEGATIVE_RESULT.exec(note);
511
+ if (negative) {
512
+ throw new Error(
513
+ `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. ` +
514
+ "Use done only for work that produced a result.",
515
+ );
516
+ }
517
+ const idea = await finishIdea(hordeDir, member, requireId(params), note || undefined);
384
518
  return closedIdea(ctx, idea, `Done #${idea.id}: ${idea.text}`);
385
519
  }
386
520
 
@@ -422,6 +556,7 @@ const HANDLERS: Partial<Record<HordeToolParams["action"], ActionHandler>> = {
422
556
  "contest-solved": contestSolvedAction,
423
557
  concerns: concernsAction,
424
558
  verify: verifyAction,
559
+ amend: amendAction,
425
560
  };
426
561
 
427
562
  export async function runHordeAction(
@@ -429,9 +564,10 @@ export async function runHordeAction(
429
564
  member: string,
430
565
  params: HordeToolParams,
431
566
  runId: string | undefined = process.env.PI_HORDE_RUN,
432
- tracking: { attempt?: string; requestId?: string } = {},
567
+ tracking: { attempt?: string; requestId?: string; role?: string | undefined } = {},
433
568
  ): Promise<ActionResult> {
434
569
  const cleanStop = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));
570
+ const role = tracking.role;
435
571
  const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...tracking, requestId: params.requestId ?? tracking.requestId };
436
572
  if (COLLABORATION_ACTIONS.has(params.action)) {
437
573
  if (!runId) throw new Error("horde: collaboration needs an active run");
@@ -444,7 +580,7 @@ export async function runHordeAction(
444
580
  const intent = audit ? await recordEvent({ ...ctx, requestId: ctx.requestId ? `${ctx.requestId}:intent` : undefined }, params.action, { outcome: "intent", payload: { input: params } }) : undefined;
445
581
  try {
446
582
  const refused = cleanStop && NEW_WORK_ACTIONS.has(params.action);
447
- const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop });
583
+ const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop, role });
448
584
  // A refusal and a no-op both record `noop`; the no-op keeps `result.noop` so the graph and the
449
585
  // report can tell an absorbed duplicate from refused work.
450
586
  if (audit) await recordEvent(ctx, params.action, { outcome: refused || result.noop ? "noop" : "applied", payload: { input: params, result }, parents: intent ? [intent.eventId] : [] });
@@ -3,15 +3,15 @@ import * as path from "node:path";
3
3
  import { listFindingsForRun } from "../ledger/findings.js";
4
4
  import { requireParam, requireText } from "../ledger/files.js";
5
5
  import { collaborationState, openMessages, projectCollaboration } from "../ledger/collaboration.js";
6
- import type { CollaborationState, PeerMessage } from "../ledger/collaboration.js";
7
- import { transactEvent } from "../ledger/events.js";
6
+ import type { ArtifactRecord, CollaborationState, PeerMessage } from "../ledger/collaboration.js";
7
+ import { readEvents, transactEvent } from "../ledger/events.js";
8
8
  import type { EventContext, EventDraft } from "../ledger/events.js";
9
9
  import { currentTarget, targetInfo, retainArtifact, hashText } from "./collaboration-target.js";
10
10
  import { collaborationView, inspectRecord } from "./collaboration-view.js";
11
11
  import { resourceTransition } from "./resource-contract.js";
12
12
  import type { HordeToolParams } from "./params.js";
13
13
 
14
- export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state", "collaboration", "inspect"]);
14
+ export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect"]);
15
15
  interface CollaborationCommand {
16
16
  ctx: EventContext;
17
17
  state: CollaborationState;
@@ -144,9 +144,13 @@ function attachEvidence(c: CollaborationCommand): EventDraft {
144
144
  if (p.revision && p.revision !== info.revision) throw new Error("horde: target revision changed");
145
145
  const file = p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {};
146
146
  const text = requireText(p.text, "attach");
147
+ // Contradicting evidence on an idea is how a lead dies; the record that outlives the run is a rule-out.
148
+ const ruleOutHint = p.relation === "contradicts" && /^I\d+$/.test(target)
149
+ ? { note: `${target} has contradicting evidence: a rule-out candidate. If this closes the lead, its owner (${info.owner}) should 'horde rule-out' it with this evidence as the reason so later runs do not retry it.` }
150
+ : {};
147
151
  return { payload: { evidenceRecord: { id: `E${seq}`, actor: ctx.actor, target, revision: info.revision,
148
152
  relation: p.relation ?? "extends", text, ...file },
149
- ...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}) } };
153
+ ...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}), ...ruleOutHint } };
150
154
  }
151
155
 
152
156
  function publishArtifact(c: CollaborationCommand): EventDraft {
@@ -167,8 +171,15 @@ function publishArtifact(c: CollaborationCommand): EventDraft {
167
171
  if (existing && existing.hash === retained.hash && (existing.criteria !== criteria || JSON.stringify(existing.dependencies) !== JSON.stringify(dependencies))) {
168
172
  throw new Error("horde: changing criteria or dependencies requires a new file revision so prior reviews cannot transfer to a different contract");
169
173
  }
174
+ // A revision other artifacts pin is a frozen contract: re-hashing it goes stale for every
175
+ // dependent, so the change is announced, and reviewers can reattest when behaviour is unchanged.
176
+ const dependents = existing ? state.artifacts.filter((a) => a.id !== existing.id && a.dependencies.some((d) => d.target === existing.id)).map((a) => a.id) : [];
177
+ if (existing && existing.hash !== retained.hash && dependents.length && !p.text?.trim()) {
178
+ throw new Error(`horde: ${existing.id}@${existing.hash.slice(0, 12)} is pinned by ${dependents.join(", ")}; prefer a follow-up finding or artifact, or republish with text= naming the change and whether behaviour is unchanged so dependents can reattest`);
179
+ }
180
+ const changeNote = existing && existing.hash !== retained.hash && p.text?.trim() ? { changeNote: p.text.trim() } : {};
170
181
  return { payload: { artifact: { id: existing?.id ?? `A${seq}`, kind, owner: ctx.actor, ...retained,
171
- criteria, dependencies } }, parents: evidenceRefs(state, p.refs) };
182
+ criteria, dependencies, ...changeNote }, ...(dependents.length ? { dependents } : {}) }, parents: evidenceRefs(state, p.refs) };
172
183
  }
173
184
 
174
185
  function recordReview(c: CollaborationCommand): EventDraft {
@@ -192,33 +203,71 @@ function recordReview(c: CollaborationCommand): EventDraft {
192
203
  text: requireParam(p.evidenceText, "evidenceText", p.action), ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
193
204
  };
194
205
  if (evidenceRecord) refs.push(evidenceRecord.id);
206
+ const limits = p.limits?.trim();
207
+ // A partial verdict refutes nothing: its unchecked scope is coverage debt that the acceptance
208
+ // names and a full review settles. Only a refutation is an objection the owner must answer.
195
209
  return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review: { id: `V${seq}`, actor: ctx.actor, target, revision: info.revision,
196
- verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
210
+ verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked, ...(limits ? { limits } : {}),
197
211
  text, evidence: refs },
198
212
  ...completeAssignedReview(c, target, `V${seq}`, text, refs),
199
- ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}),
200
- ...(p.verdict === "supports" && !p.unchecked.trim() ? coverageClearance(c, target, info, `V${seq}`, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
201
- }
202
-
203
- /** The challenge a partially-supports review raised in its own event, as opposed to an objection a member wrote. */
204
- function coverageChallenge(state: CollaborationState, message: PeerMessage): boolean {
205
- return state.reviews.some((r) => r.id === `V${message.id.slice(1)}` && r.actor === message.sender && r.verdict === "partially-supports");
213
+ ...(p.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
206
214
  }
207
215
 
208
216
  /**
209
- * A partial review opens a blocking challenge that only its sender may resolve. When that review
210
- * contradicted nothing and another member later supports the whole revision, the unchecked scope
211
- * is covered: the full review clears the challenge, and the resolution names both reviews.
217
+ * A behaviour-neutral re-hash (a republish against a corrected dependency, an amended finding
218
+ * body, a spec clarification) would otherwise drop every review of the earlier revision. The
219
+ * reviewer carries its own latest review forward with evidence that what it checked is unchanged.
220
+ */
221
+ /**
222
+ * What changed between the reviewed revision and the current one, as the ledger recorded it. A
223
+ * review carries forward only across prose: a new acceptance contract or a new claim line is a
224
+ * different subject and needs a fresh review.
212
225
  */
213
- function coverageClearance(c: CollaborationCommand, target: string, info: { revision: string }, reviewId: string, refs: string[]): Record<string, unknown> {
214
- const cleared = c.state.messages.filter((m) =>
215
- m.target === target && m.revision === info.revision && m.blocking && m.state !== "resolved" && m.sender !== c.ctx.actor && coverageChallenge(c.state, m));
216
- if (!cleared.length) return {};
217
- return { clearances: cleared.map((m) => ({
218
- id: m.id,
219
- text: `cleared by ${reviewId}: ${c.ctx.actor}'s full-scope supporting review of ${target}@${info.revision.slice(0, 12)} covers the scope V${m.id.slice(1)} left unchecked`,
220
- refs,
221
- })) };
226
+ function reattestBlocker(ctx: EventContext, state: CollaborationState, target: string, previousRevision: string): string | undefined {
227
+ const events = readEvents(ctx.hordeDir, ctx.runId).filter((e) => e.outcome === "applied");
228
+ if (target.startsWith("A")) {
229
+ const current = state.artifacts.find((a) => a.id === target);
230
+ const earlier = events.map((e) => e.payload.artifact as ArtifactRecord | undefined).find((a) => a?.id === target && a.hash === previousRevision);
231
+ if (!current || !earlier) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not on record`;
232
+ if (earlier.criteria !== current.criteria) return `${target}'s criteria changed since the reviewed revision; review the new contract`;
233
+ if ((earlier.kind ?? "runnable") !== (current.kind ?? "runnable")) return `${target}'s kind changed since the reviewed revision`;
234
+ return undefined;
235
+ }
236
+ const amends = events.filter((e) => e.action === "amend").map((e) => e.payload.result as { id?: number; previousRevision?: string; titleChanged?: boolean } | undefined);
237
+ const start = amends.findIndex((a) => a?.id === Number(target.slice(1)) && a.previousRevision === previousRevision);
238
+ if (start === -1) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not the revision an amend started from; review the current text`;
239
+ if (amends.slice(start).some((a) => a?.id === Number(target.slice(1)) && a.titleChanged)) return `${target}'s claim line changed since the reviewed revision; review the new claim`;
240
+ return undefined;
241
+ }
242
+
243
+ function reattestReview(c: CollaborationCommand): EventDraft {
244
+ const { ctx, state, params: p, seq } = c;
245
+ const target = requireParam(p.target, "target", p.action);
246
+ if (!/^[FA]\d+$/.test(target)) throw new Error("horde: reattest targets an F# or A#");
247
+ const info = targetInfo(ctx, state, target);
248
+ if (info.owner === ctx.actor) throw new Error("horde: an independent review needs another member");
249
+ if (p.revision !== info.revision) throw new Error("horde: reattest must name the current revision from inspect");
250
+ const previous = state.reviews.findLast((r) => r.target === target && r.actor === ctx.actor && r.revision !== info.revision);
251
+ if (!previous) throw new Error(`horde: no earlier review of ${target} by ${ctx.actor} to carry forward; record a review instead`);
252
+ const blocker = reattestBlocker(ctx, state, target, previous.revision);
253
+ if (blocker) throw new Error(`horde: cannot reattest: ${blocker}`);
254
+ if (state.reviews.some((r) => r.target === target && r.actor === ctx.actor && r.revision === info.revision)) {
255
+ throw new Error(`horde: ${ctx.actor} already reviewed ${target}@${info.revision.slice(0, 12)}`);
256
+ }
257
+ const refs = [...evidenceRefs(state, p.refs)];
258
+ if (!refs.length && !p.evidenceText) throw new Error("horde: reattest needs evidence that the checked behaviour is unchanged: refs or evidenceText");
259
+ const stale = staleEvidence(state, refs, target, info.revision);
260
+ if (stale) throw new Error(`horde: reattest evidence must address the current revision: ${stale}`);
261
+ const text = requireText(p.text, "reattest");
262
+ const evidenceRecord = p.evidenceText === undefined ? undefined : {
263
+ id: `E${seq}`, actor: ctx.actor, target, revision: info.revision, relation: "extends" as const,
264
+ text: p.evidenceText, ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
265
+ };
266
+ if (evidenceRecord) refs.push(evidenceRecord.id);
267
+ const review = { ...previous, id: `V${seq}`, revision: info.revision, text, evidence: refs, reattests: previous.id };
268
+ return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review,
269
+ ...completeAssignedReview(c, target, `V${seq}`, text, refs),
270
+ ...(review.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: [previous.id, ...refs.filter((id) => id !== evidenceRecord?.id)] };
222
271
  }
223
272
 
224
273
  /** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
@@ -239,6 +288,7 @@ function decide(c: CollaborationCommand): EventDraft {
239
288
  case "message": return newMessage(c);
240
289
  case "attach": return attachEvidence(c);
241
290
  case "review": return recordReview(c);
291
+ case "reattest": return reattestReview(c);
242
292
  case "artifact": return publishArtifact(c);
243
293
  case "resource-state": return resourceTransition(c);
244
294
  default: return transition(c);
@@ -28,19 +28,20 @@ export const HordeParams = Type.Object({
28
28
  "confirm-solved",
29
29
  "contest-solved",
30
30
  "concerns",
31
- "verify", "inbox", "message", "ack", "respond", "resolve", "defer",
32
- "attach", "review", "artifact", "resource-state", "collaboration", "inspect",
31
+ "verify", "amend", "inbox", "message", "ack", "respond", "resolve", "defer",
32
+ "attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect",
33
33
  ] as const,
34
34
  {
35
35
  description:
36
36
  "list/findings/facts/claims/resources/concerns: read shared state; post: BOARD.md; " +
37
37
  "idea/claim/done/drop/rule-out: checklist (rule-out is a terminal negative); " +
38
38
  "finding: FINDINGS.md with a claimed idea, a concern slug, and optional importance/" +
39
- "confidence/support; " +
39
+ "confidence/support; amend: rewrite the body prose of your own F#id at its current revision; " +
40
40
  "verify: record your independent reproduction of finding F#id with the support level you reached; " +
41
41
  "fact: FACTS.md; assert/confirm/contest/retract/supersede: change claim C#id state; " +
42
42
  "resource: claim a shared dataset, harness, corpus, or sandbox (with cleanup) before building it; " +
43
- "solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it",
43
+ "solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it; " +
44
+ "reattest: carry your earlier review of F#/A# forward to its current revision with evidence that the checked behaviour is unchanged",
44
45
  },
45
46
  ),
46
47
  detail: Type.Optional(StringEnum(["summary", "full"] as const, { description: "Collaboration reads default to summaries; full returns complete page bodies, inspected subjects or write receipts. inspect E#/V#/Q# always returns the full record." })),
@@ -57,7 +58,8 @@ export const HordeParams = Type.Object({
57
58
  relation: Type.Optional(StringEnum(["supports", "contradicts", "extends"] as const)),
58
59
  verdict: Type.Optional(StringEnum(["supports", "partially-supports", "refutes", "inconclusive"] as const)),
59
60
  checked: Type.Optional(Type.String({ description: "Exact assertions checked and method; required for review." })),
60
- unchecked: Type.Optional(Type.String({ description: "Assertions left unchecked or limitations; empty only for full coverage." })),
61
+ unchecked: Type.Optional(Type.String({ description: "Assertions inside the target's stated claim or criteria that you did not check; empty only for full coverage of that scope. Disclosures outside the criteria go in limits." })),
62
+ limits: Type.Optional(Type.String({ description: "For review: limits outside the target's declared claim or criteria (one engine, no hardened deployment). Recorded on the acceptance as limits, never as blockers." })),
61
63
  artifactKind: Type.Optional(StringEnum(["runnable", "document"] as const, { description: "Artifact classification, immutable after publication. Default runnable requires tested/reproduced review. document permits full-scope sourced/derived review of its stated criteria, not runtime acceptance." })),
62
64
  artifactPath: Type.Optional(Type.String({ description: "File path relative to this run, under artifacts/, retained by content hash." })),
63
65
  criteria: Type.Optional(Type.String({ description: "Artifact acceptance criteria, including positive/negative tests." })),
@@ -168,8 +170,8 @@ export type HordeToolParams = {
168
170
  | "confirm-solved"
169
171
  | "contest-solved"
170
172
  | "concerns"
171
- | "verify" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
172
- | "attach" | "review" | "artifact" | "resource-state" | "collaboration" | "inspect";
173
+ | "verify" | "amend" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
174
+ | "attach" | "review" | "reattest" | "artifact" | "resource-state" | "collaboration" | "inspect";
173
175
  detail?: "summary" | "full";
174
176
  offset?: number;
175
177
  limit?: number;
@@ -185,6 +187,7 @@ export type HordeToolParams = {
185
187
  verdict?: "supports" | "partially-supports" | "refutes" | "inconclusive";
186
188
  checked?: string;
187
189
  unchecked?: string;
190
+ limits?: string;
188
191
  artifactPath?: string;
189
192
  artifactKind?: "runnable" | "document";
190
193
  criteria?: string;
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { cleanStopRequestedFor, writeHeartbeat } from "../ledger/run-signals.js";
4
4
  import { envNumber } from "../format.js";
5
5
  import { ReminderGate, goalReminder } from "../reminder.js";
6
- import { runHordeAction } from "./actions.js";
6
+ import { isVerifierRole, runHordeAction } from "./actions.js";
7
7
  import { pendingVerificationHint, solvedHint } from "./hints.js";
8
8
  import { HordeParams } from "./params.js";
9
9
  import { compactReceipt } from "./collaboration-view.js";
@@ -18,9 +18,12 @@ const REMIND_MS = envNumber("PI_HORDE_REMIND_MS", 600_000);
18
18
  const MUTATING_ACTIONS = new Set([
19
19
  "post", "idea", "claim", "done", "drop", "rule-out", "finding", "fact", "assert", "confirm",
20
20
  "contest", "retract", "supersede", "resource", "solved", "confirm-solved", "contest-solved",
21
- "verify", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state",
21
+ "verify", "amend", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state",
22
22
  ]);
23
23
 
24
+ /** After these, a verifier is shown what it still has to review. */
25
+ const VERIFIER_QUEUE_ACTIONS = new Set(["verify", "review", "reattest", "post", "findings"]);
26
+
24
27
  export function registerMemberTool(pi: ExtensionAPI): void {
25
28
  const hordeDir = process.env.PI_HORDE_DIR;
26
29
  const member = process.env.PI_HORDE_NAME;
@@ -60,13 +63,15 @@ export function registerMemberTool(pi: ExtensionAPI): void {
60
63
  parameters: HordeParams,
61
64
  async execute(toolCallId, params) {
62
65
  const runId = process.env.PI_HORDE_RUN;
63
- const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId });
66
+ const role = process.env.PI_HORDE_ROLE;
67
+ const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId, role });
64
68
  const runDir = runId ? path.join(hordeDir, "runs", runId) : undefined;
65
69
  if (runDir && MUTATING_ACTIONS.has(params.action)) touchHeartbeat(runDir, member);
66
70
  let text = compactReceipt(result.text, params) + solvedHint(hordeDir, runId, member, params.action);
67
- // A verifier's coverage claim is only a snapshot. Append the live pending queue
68
- // after every verify so a finding filed later cannot read as already checked.
69
- if (runId && params.action === "verify" && /verifier/i.test(process.env.PI_HORDE_ROLE ?? "")) {
71
+ // A verifier's coverage claim is only a snapshot. Append the live pending queue after every
72
+ // review and every board update, so the unreviewed F# are named before wind-down and a
73
+ // finding filed later cannot read as already checked.
74
+ if (runId && VERIFIER_QUEUE_ACTIONS.has(params.action) && isVerifierRole(role)) {
70
75
  text += pendingVerificationHint(hordeDir, runId, member);
71
76
  }
72
77
  const winding = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));