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/README.md +42 -10
- package/docs/REFERENCE.md +141 -27
- package/package.json +1 -1
- package/src/args.ts +125 -50
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +390 -95
- package/src/dashboard.ts +205 -96
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +62 -3
- package/src/prompt.ts +102 -21
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +32 -15
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +30 -21
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +36 -25
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +77 -10
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +70 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +16 -9
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +206 -41
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +13 -6
- package/src/widget.ts +80 -8
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
|
|
120
|
+
const base = `/runs/${encodeURIComponent(runId)}`;
|
|
121
121
|
switch (letter) {
|
|
122
|
-
case "I": return
|
|
123
|
-
case "F": return
|
|
124
|
-
case "T": return
|
|
125
|
-
case "C": return
|
|
126
|
-
case "R": return
|
|
127
|
-
case "A": case "V": case "Q": return
|
|
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
|
|
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
|
+
}
|
package/src/tool/actions.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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] : [] });
|