agent-horde 0.1.4 → 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.
- package/README.md +23 -9
- package/SECURITY.md +8 -0
- package/docs/REFERENCE.md +73 -16
- package/package.json +1 -1
- package/src/args.ts +18 -3
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-export.ts +192 -0
- package/src/dashboard-html.ts +88 -21
- package/src/dashboard.ts +114 -12
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +15 -3
- package/src/prompt.ts +73 -16
- package/src/run/completions.ts +6 -1
- package/src/run/finalize.ts +6 -7
- package/src/run/launch.ts +1 -1
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +2 -1
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +74 -9
- package/src/run/types.ts +6 -0
- package/src/run-graph.ts +9 -2
- package/src/tool/actions.ts +149 -13
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +11 -6
package/src/tool/actions.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
package/src/tool/params.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/tool/register.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
69
|
-
|
|
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));
|