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.
- package/README.md +21 -8
- package/docs/REFERENCE.md +55 -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-html.ts +11 -3
- 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/ledger/facts.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { oneLine } from "../format.js";
|
|
3
|
+
import { listClaims } from "./claims.js";
|
|
4
|
+
import { CLUSTER_THRESHOLD } from "./clusters.js";
|
|
3
5
|
import { appendEntry, hordePaths, requireText, writeFileAtomic } from "./files.js";
|
|
6
|
+
import { listIdeas } from "./ideas.js";
|
|
4
7
|
import { withHordeLock } from "./lock.js";
|
|
5
|
-
import { bestTextMatch, factAnchors, salientTokens, stripLeadingId } from "./text-match.js";
|
|
8
|
+
import { bestTextMatch, factAnchors, hardOverlap, salientTokens, stripLeadingId, textOverlap } from "./text-match.js";
|
|
6
9
|
|
|
7
10
|
export interface FactEntry {
|
|
8
11
|
id: number;
|
|
@@ -10,6 +13,8 @@ export interface FactEntry {
|
|
|
10
13
|
author: string;
|
|
11
14
|
deltaOf?: number | undefined;
|
|
12
15
|
corroborators: string[];
|
|
16
|
+
/** The open idea (`#12`) or contested claim (`C3`) that was still auditing the subject when the fact was filed. */
|
|
17
|
+
provisionalOn?: string | undefined;
|
|
13
18
|
headingLine: number;
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -23,18 +28,58 @@ export function parseFacts(content: string): FactEntry[] {
|
|
|
23
28
|
const meta = match[4] ?? "";
|
|
24
29
|
const delta = /delta=#?T?(\d+)/.exec(meta);
|
|
25
30
|
const corr = /corr=([^\s]+)/.exec(meta);
|
|
31
|
+
const prov = /prov=([^\s]+)/.exec(meta);
|
|
26
32
|
facts.push({
|
|
27
33
|
id: Number(match[1]),
|
|
28
34
|
text: match[2] ?? "",
|
|
29
35
|
author: match[3] ?? "",
|
|
30
36
|
deltaOf: delta ? Number(delta[1]) : undefined,
|
|
31
37
|
corroborators: corr?.[1] ? corr[1].split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
38
|
+
provisionalOn: prov?.[1],
|
|
32
39
|
headingLine: lineIndex,
|
|
33
40
|
});
|
|
34
41
|
});
|
|
35
42
|
return facts;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A fact asserting what an open idea is still auditing, or what a contested claim disputes, is
|
|
47
|
+
* provisional: it is recorded, but the answer may not quote it as settled until the idea closes
|
|
48
|
+
* or the claim resolves. Returns the subject it is provisional on, if any.
|
|
49
|
+
*/
|
|
50
|
+
function provisionalSubject(hordeDir: string, text: string): string | undefined {
|
|
51
|
+
const matches = (candidates: string[]): number | undefined => {
|
|
52
|
+
const match = textOverlap(candidates, text);
|
|
53
|
+
if (!match) return undefined;
|
|
54
|
+
return hardOverlap(match) || Math.max(match.jaccard, match.overlap) >= CLUSTER_THRESHOLD ? match.index : undefined;
|
|
55
|
+
};
|
|
56
|
+
const open = listIdeas(hordeDir).filter((idea) => idea.mark === " " || idea.mark === "~");
|
|
57
|
+
const idea = matches(open.map((entry) => entry.text));
|
|
58
|
+
if (idea !== undefined) return `#${open[idea]?.id}`;
|
|
59
|
+
const contested = listClaims(hordeDir).filter((claim) => claim.state === "contested");
|
|
60
|
+
const claim = matches(contested.map((entry) => entry.statement));
|
|
61
|
+
return claim === undefined ? undefined : `C${contested[claim]?.id}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether a fact's provisional subject is still open: an unclosed idea or a still-contested claim. */
|
|
65
|
+
export function factUnsettled(hordeDir: string, fact: FactEntry): boolean {
|
|
66
|
+
if (!fact.provisionalOn) return false;
|
|
67
|
+
if (fact.provisionalOn.startsWith("#")) {
|
|
68
|
+
const idea = listIdeas(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
|
|
69
|
+
return idea !== undefined && (idea.mark === " " || idea.mark === "~");
|
|
70
|
+
}
|
|
71
|
+
const claim = listClaims(hordeDir).find((entry) => entry.id === Number(fact.provisionalOn?.slice(1)));
|
|
72
|
+
return claim?.state === "contested";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** T# ids a text quotes whose provisional subject has not closed, each named with that subject. */
|
|
76
|
+
export function unsettledFactCitations(hordeDir: string, text: string): string[] {
|
|
77
|
+
const cited = new Set([...text.matchAll(/\bT(\d+)\b/g)].map((m) => Number(m[1])));
|
|
78
|
+
return listFacts(hordeDir)
|
|
79
|
+
.filter((fact) => cited.has(fact.id) && factUnsettled(hordeDir, fact))
|
|
80
|
+
.map((fact) => `T${fact.id} (provisional on ${fact.provisionalOn})`);
|
|
81
|
+
}
|
|
82
|
+
|
|
38
83
|
/** Record a second reporter of the same fact on its heading, mirroring findings. */
|
|
39
84
|
function addFactCorroborator(content: string, entry: FactEntry, author: string): string {
|
|
40
85
|
if (entry.author === author || entry.corroborators.includes(author)) return content;
|
|
@@ -46,9 +91,10 @@ function addFactCorroborator(content: string, entry: FactEntry, author: string):
|
|
|
46
91
|
return lines.join("\n");
|
|
47
92
|
}
|
|
48
93
|
|
|
49
|
-
function factEntry(author: string, text: string, id: number, deltaOf?: number): string {
|
|
94
|
+
function factEntry(author: string, text: string, id: number, deltaOf?: number, provisionalOn?: string): string {
|
|
50
95
|
const delta = deltaOf !== undefined ? ` delta=#T${deltaOf}` : "";
|
|
51
|
-
|
|
96
|
+
const prov = provisionalOn ? ` prov=${provisionalOn}` : "";
|
|
97
|
+
return `\n- [T${id}] ${text} @${author}${delta}${prov}\n`;
|
|
52
98
|
}
|
|
53
99
|
|
|
54
100
|
interface AddFactResult {
|
|
@@ -117,8 +163,9 @@ export async function addFact(
|
|
|
117
163
|
if (hit) return accrete(hit);
|
|
118
164
|
}
|
|
119
165
|
const id = facts.reduce((max, fact) => Math.max(max, fact.id), 0) + 1;
|
|
120
|
-
|
|
121
|
-
|
|
166
|
+
const provisionalOn = provisionalSubject(hordeDir, body);
|
|
167
|
+
appendEntry(factsPath, factEntry(author, body, id, deltaOf, provisionalOn));
|
|
168
|
+
return { fact: { id, text: body, author, deltaOf, corroborators: [], provisionalOn, headingLine: -1 } };
|
|
122
169
|
});
|
|
123
170
|
}
|
|
124
171
|
|
package/src/ledger/findings.ts
CHANGED
|
@@ -242,7 +242,7 @@ function findingEntry(
|
|
|
242
242
|
if (meta.concern) tag += ` concern=${meta.concern}`;
|
|
243
243
|
if (meta.deltaOf !== undefined) tag += ` delta=#F${meta.deltaOf}`;
|
|
244
244
|
if (meta.supersedes !== undefined) tag += ` supersedes=#F${meta.supersedes}`;
|
|
245
|
-
return `\n### ${formatClock(now)} ${author}${tag}\n${neutralizeHeadings(text.trim())}\n`;
|
|
245
|
+
return `\n### ${formatClock(now)} ${author}${tag}\n${neutralizeNotes(neutralizeHeadings(text.trim()))}\n`;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** Record another independent member on an existing finding's heading. */
|
|
@@ -495,6 +495,96 @@ export async function verifyFinding(
|
|
|
495
495
|
});
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
+
interface AmendFindingInput {
|
|
499
|
+
id: number;
|
|
500
|
+
/** The whole replacement text: title line first, as for `finding`. */
|
|
501
|
+
text: string | undefined;
|
|
502
|
+
/** Why the prose changed; recorded in the body so the revision carries its own errata note. */
|
|
503
|
+
note: string | undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Rewrite a finding's prose in place at its current revision. Evidence appends cannot fix a
|
|
508
|
+
* sentence and `supersedes` replaces whole findings, so a refuted sentence stayed in FINDINGS.md
|
|
509
|
+
* at freeze. The subject hash changes, reviews of the old revision stay pinned to it, and
|
|
510
|
+
* reviewers reattest when what they checked is unchanged. Verification notes are kept.
|
|
511
|
+
*/
|
|
512
|
+
export async function amendFinding(
|
|
513
|
+
hordeDir: string,
|
|
514
|
+
runId: string,
|
|
515
|
+
member: string,
|
|
516
|
+
{ id, text, note }: AmendFindingInput,
|
|
517
|
+
): Promise<{ finding: FindingEntry; previous: string; titleChanged: boolean }> {
|
|
518
|
+
const body = stripLeadingId(requireText(text, "amend")).trim();
|
|
519
|
+
const reason = oneLineReason(note);
|
|
520
|
+
return withHordeLock(hordeDir, member, () => {
|
|
521
|
+
const findingsPath = hordePaths(hordeDir).findings;
|
|
522
|
+
const content = readStampedFindings(findingsPath);
|
|
523
|
+
const all = parseFindings(content);
|
|
524
|
+
const entry = all.find((f) => f.runId === runId && f.id === id);
|
|
525
|
+
if (!entry) throw new Error(`horde: finding F${id} is not in run ${runId}; amend only this run's findings`);
|
|
526
|
+
if (entry.author !== member) throw new Error(`horde: F${id} belongs to ${entry.author}; a correction by another member is a finding with supersedes: ${id}`);
|
|
527
|
+
if (entry.supersededBy !== undefined) throw new Error(`horde: F${id} was superseded by F${entry.supersededBy}; amend the successor`);
|
|
528
|
+
const scoped = all.filter((f) => f.runId === runId && f.id !== id);
|
|
529
|
+
checkBodyReferences(scoped, { runId, author: member, body, meta: { supersedes: entry.supersedes, deltaOf: entry.deltaOf } });
|
|
530
|
+
const previous = findingRevision(entry);
|
|
531
|
+
const subjectEnd = entry.body.search(/\n(?:Verified|Reviewed) by /);
|
|
532
|
+
// Verification notes were written against the previous subject; they stay as history, labelled
|
|
533
|
+
// as such, and the heading's legacy verified label goes so the new text reads as unreviewed.
|
|
534
|
+
const notes = subjectEnd === -1 ? "" : `\n${entry.body.slice(subjectEnd)}`;
|
|
535
|
+
const predate = notes ? ` (verification notes below were recorded against revision ${previous.slice(0, 12)})` : "";
|
|
536
|
+
const amended = `${neutralizeNotes(neutralizeHeadings(body))}\nAmended ${formatClock(new Date())} by ${member}: ${reason}${predate}${notes}`;
|
|
537
|
+
const lines = content.split("\n");
|
|
538
|
+
// Corroborators and the legacy verifier confirmed the previous text; they confirm the new one again.
|
|
539
|
+
lines[entry.headingLine] = (lines[entry.headingLine] ?? "").replace(/\s+(?:verified|vsupport|corr)=[^\s]+/g, "");
|
|
540
|
+
lines.splice(entry.headingLine + 1, entry.body.split("\n").length, ...amended.split("\n"));
|
|
541
|
+
writeFileAtomic(findingsPath, lines.join("\n"));
|
|
542
|
+
const title = amended.split("\n")[0] ?? "";
|
|
543
|
+
const finding = { ...entry, body: amended, title, verifiedBy: undefined, verifiedSupport: undefined, corroborators: [] };
|
|
544
|
+
return { finding, previous, titleChanged: title.trim() !== entry.title.trim() };
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function oneLineReason(note: string | undefined): string {
|
|
549
|
+
const reason = (note ?? "").replace(/\s+/g, " ").trim();
|
|
550
|
+
if (!reason) throw new Error("horde: amend needs note=<why the prose changed>, so the revision carries its errata");
|
|
551
|
+
return reason.slice(0, 300);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Superseded findings a text still cites, each with its successor. */
|
|
555
|
+
export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
|
|
556
|
+
return findings
|
|
557
|
+
.filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
|
|
558
|
+
.map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The current revision of every live finding a text cites, pinned so a later amend is visible. */
|
|
562
|
+
export function answerCitations(findings: FindingEntry[], text: string): Record<string, string> {
|
|
563
|
+
const cites: Record<string, string> = {};
|
|
564
|
+
for (const finding of findings) {
|
|
565
|
+
if (finding.supersededBy === undefined && new RegExp(`\\bF${finding.id}\\b`).test(text)) cites[`F${finding.id}`] = findingRevision(finding);
|
|
566
|
+
}
|
|
567
|
+
return cites;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Citations an answer can no longer stand on: findings superseded since it was written, and
|
|
572
|
+
* findings amended since the revisions it pinned. Both ship a stale reading as the result.
|
|
573
|
+
*/
|
|
574
|
+
export function staleAnswerCitations(findings: FindingEntry[], text: string, cites: Record<string, string> = {}): string[] {
|
|
575
|
+
const stale = supersededCitations(text, findings).map((citation) => `superseded ${citation}`);
|
|
576
|
+
for (const [target, revision] of Object.entries(cites)) {
|
|
577
|
+
const finding = findings.find((entry) => `F${entry.id}` === target);
|
|
578
|
+
if (finding && finding.supersededBy === undefined && findingRevision(finding) !== revision) stale.push(`amended ${target}`);
|
|
579
|
+
}
|
|
580
|
+
return stale;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** A body line that reads as a verification note would move the subject boundary the revision hashes. */
|
|
584
|
+
function neutralizeNotes(text: string): string {
|
|
585
|
+
return text.replace(/^((?:Verified|Reviewed) by )/gm, "\\$1");
|
|
586
|
+
}
|
|
587
|
+
|
|
498
588
|
export function listFindingsForRun(hordeDir: string, runId: string): FindingEntry[] {
|
|
499
589
|
const findingsPath = hordePaths(hordeDir).findings;
|
|
500
590
|
if (!fs.existsSync(findingsPath)) return [];
|
|
@@ -81,6 +81,8 @@ export interface SolvedSignal {
|
|
|
81
81
|
member: string;
|
|
82
82
|
text: string;
|
|
83
83
|
at: string;
|
|
84
|
+
/** Revision of each finding the text cited when it was proposed, so a later amend is visible. */
|
|
85
|
+
cites?: Record<string, string>;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
function solvedPath(runDir: string): string {
|
|
@@ -127,7 +129,8 @@ function readSignal(file: string): SolvedSignal | undefined {
|
|
|
127
129
|
try {
|
|
128
130
|
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<SolvedSignal>;
|
|
129
131
|
if (typeof parsed?.member === "string" && typeof parsed?.text === "string") {
|
|
130
|
-
|
|
132
|
+
const cites = parsed.cites && typeof parsed.cites === "object" ? parsed.cites : undefined;
|
|
133
|
+
return { member: parsed.member, text: parsed.text, at: parsed.at ?? "", revision: parsed.revision, ...(cites ? { cites } : {}) };
|
|
131
134
|
}
|
|
132
135
|
} catch {
|
|
133
136
|
// No signal, or a torn write; treat as none.
|
|
@@ -195,6 +198,7 @@ export async function markSolved(
|
|
|
195
198
|
runId: string,
|
|
196
199
|
member: string,
|
|
197
200
|
text: string | undefined,
|
|
201
|
+
cites?: Record<string, string>,
|
|
198
202
|
): Promise<{ signal: SolvedSignal; alreadySolved: boolean }> {
|
|
199
203
|
// The full answer is stored untruncated; only the board line is shortened.
|
|
200
204
|
const full = requireText(text, "solved").trim();
|
|
@@ -203,7 +207,7 @@ export async function markSolved(
|
|
|
203
207
|
return withHordeLock(hordeDir, member, () => {
|
|
204
208
|
const existing = readSolved(runDir);
|
|
205
209
|
if (existing) return { signal: existing, alreadySolved: true };
|
|
206
|
-
const signal: SolvedSignal = { member, text: full, at: new Date().toISOString(), revision: randomUUID() };
|
|
210
|
+
const signal: SolvedSignal = { member, text: full, at: new Date().toISOString(), revision: randomUUID(), ...(cites ? { cites } : {}) };
|
|
207
211
|
writeFileAtomic(solvedPath(runDir), `${JSON.stringify(signal)}\n`);
|
|
208
212
|
// Capture the answer immediately so it survives even if the run is killed
|
|
209
213
|
// before finalize; the status is upgraded on validation and at finalize.
|
|
@@ -213,6 +217,17 @@ export async function markSolved(
|
|
|
213
217
|
});
|
|
214
218
|
}
|
|
215
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Put a withdrawn or contested proposal back before the members, under a new revision so a
|
|
222
|
+
* confirmation of the old one cannot validate it. Call under the horde lock.
|
|
223
|
+
*/
|
|
224
|
+
export function reopenSolved(runDir: string, runId: string, proposal: SolvedSignal, status: string, cites?: Record<string, string>): SolvedSignal {
|
|
225
|
+
const signal: SolvedSignal = { member: proposal.member, text: proposal.text, at: new Date().toISOString(), revision: randomUUID(), ...(cites ? { cites } : {}) };
|
|
226
|
+
writeFileAtomic(solvedPath(runDir), `${JSON.stringify(signal)}\n`);
|
|
227
|
+
writeAnswer(runDir, runId, signal, status);
|
|
228
|
+
return signal;
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
interface SolvedAnswer {
|
|
217
232
|
ok: boolean;
|
|
218
233
|
reason: string;
|
package/src/member.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface MemberState {
|
|
|
55
55
|
summaryIncomplete?: boolean | undefined;
|
|
56
56
|
/** How many fresh-context sessions replaced this member's earlier ones. */
|
|
57
57
|
resets?: number | undefined;
|
|
58
|
+
/** Fresh sessions an `--until-stop` run gave this member after an earlier one finished. */
|
|
59
|
+
continuations?: number | undefined;
|
|
58
60
|
/** Set by the parent before it kills a stalled member, so the exit is treated as a reset. */
|
|
59
61
|
resetPending?: string | undefined;
|
|
60
62
|
/** Extra identity line for a replacement session, naming what was reset and why. */
|
|
@@ -81,6 +83,8 @@ export interface MemberRunInfo {
|
|
|
81
83
|
runDir: string;
|
|
82
84
|
prompt: string;
|
|
83
85
|
count: number;
|
|
86
|
+
/** True when started with `--until-stop`: the run continues until the user stops it. */
|
|
87
|
+
untilStop?: boolean | undefined;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
interface SpawnMemberInput {
|
|
@@ -283,11 +287,19 @@ export function memberSessionDir(runDir: string, member: string): string {
|
|
|
283
287
|
return path.join(runDir, "sessions", member);
|
|
284
288
|
}
|
|
285
289
|
|
|
286
|
-
/**
|
|
287
|
-
|
|
290
|
+
/** What an `--until-stop` member does instead of stopping when the board runs dry. */
|
|
291
|
+
export const UNTIL_STOP_NOTE =
|
|
292
|
+
"This run continues until the user stops it. When nothing worthwhile remains on the board, add " +
|
|
293
|
+
"ideas that extend, stress-test, or deepen the work and pursue them instead of stopping. Propose " +
|
|
294
|
+
"SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
|
|
295
|
+
"materially better one exists.";
|
|
296
|
+
|
|
297
|
+
/** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
|
|
298
|
+
function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
|
|
288
299
|
const parts = [`You are ${member.name}.`];
|
|
289
300
|
if (role) parts.push(role);
|
|
290
301
|
if (member.resetNote) parts.push(member.resetNote);
|
|
302
|
+
if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
|
|
291
303
|
return parts.join(" ");
|
|
292
304
|
}
|
|
293
305
|
|
|
@@ -297,7 +309,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
|
|
|
297
309
|
"--mode", "json", "-p",
|
|
298
310
|
"--session-dir", memberSessionDir(run.runDir, member.name),
|
|
299
311
|
"--append-system-prompt", path.join(run.runDir, "SYSTEM.md"),
|
|
300
|
-
"--append-system-prompt", identityLine(member, input.role),
|
|
312
|
+
"--append-system-prompt", identityLine(run, member, input.role),
|
|
301
313
|
];
|
|
302
314
|
if (input.model) args.push("--model", `${input.model.provider}/${input.model.id}`);
|
|
303
315
|
if (input.thinkingLevel) args.push("--thinking", input.thinkingLevel);
|
package/src/prompt.ts
CHANGED
|
@@ -58,17 +58,36 @@ export interface RunSummaryInput {
|
|
|
58
58
|
memberPosts?: Map<string, string> | undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
interface MemberContribution {
|
|
62
|
+
findings: number;
|
|
63
|
+
verified: number;
|
|
64
|
+
reviewed: number;
|
|
65
|
+
/** Current-revision reviews by this member with nothing left unchecked. */
|
|
66
|
+
fullScope: number;
|
|
67
|
+
/** Other members' current findings this member has not reviewed at their current revision. */
|
|
68
|
+
unreviewed: number[];
|
|
69
|
+
ideasClosed: number;
|
|
70
|
+
/** True for the member that held the verifier role; its coverage line is printed. */
|
|
71
|
+
verifier: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
/** Findings filed, verified, and claimed ideas closed by one member, for the summary. */
|
|
62
75
|
function memberContribution(
|
|
63
76
|
name: string,
|
|
64
77
|
findings: FindingEntry[],
|
|
65
78
|
ideas: Idea[],
|
|
66
79
|
reviews: ReviewRecord[] = [],
|
|
67
|
-
|
|
80
|
+
verifier = false,
|
|
81
|
+
): MemberContribution {
|
|
82
|
+
const current = findings.filter((f) => f.supersededBy === undefined);
|
|
83
|
+
const ownReview = (f: FindingEntry): ReviewRecord | undefined => findingReviewState(f, reviews).reviews.find((r) => r.actor === name);
|
|
68
84
|
return {
|
|
85
|
+
verifier,
|
|
69
86
|
findings: findings.filter((finding) => finding.author === name).length,
|
|
70
87
|
verified: findings.filter((finding) => finding.verifiedBy === name).length,
|
|
71
|
-
reviewed:
|
|
88
|
+
reviewed: current.filter((f) => ownReview(f) !== undefined).length,
|
|
89
|
+
fullScope: current.filter((f) => !ownReview(f)?.unchecked.trim() && ownReview(f) !== undefined).length,
|
|
90
|
+
unreviewed: current.filter((f) => f.author !== name && ownReview(f) === undefined).map((f) => f.id),
|
|
72
91
|
ideasClosed: ideas.filter((idea) => idea.mark === "x" && idea.owner === name).length,
|
|
73
92
|
};
|
|
74
93
|
}
|
|
@@ -82,8 +101,10 @@ export const VERIFIER_ROLE =
|
|
|
82
101
|
"You are the verifier. Reproduce the highest-importance unverified findings first. Record each " +
|
|
83
102
|
"check with 'horde inspect', 'horde attach', then 'horde review': name the exact revision, assertions " +
|
|
84
103
|
"checked, unchecked assertions, method, evidence refs and support. Source reasoning is not a live reproduction. When a finding does not reproduce, send a blocking challenge to its owner and contest its claim C# if one exists, or " +
|
|
85
|
-
"file a finding with supersedes: F# under the same idea and concern.
|
|
86
|
-
"
|
|
104
|
+
"file a finding with supersedes: F# under the same idea and concern. The tool refuses your idea claims " +
|
|
105
|
+
"while other members' findings await your review, and a session that closes with high-importance " +
|
|
106
|
+
"findings unreviewed is replaced by a fresh one that owes them. Your summary's coverage sentence is " +
|
|
107
|
+
"derived from your review records, so name what you left unreviewed rather than calling the work verified.";
|
|
87
108
|
|
|
88
109
|
const COLLABORATION_PROTOCOL = `
|
|
89
110
|
Collaboration loop:
|
|
@@ -114,14 +135,29 @@ Collaboration loop:
|
|
|
114
135
|
review can accept a document; runnable outputs require tested/reproduced support. Document acceptance
|
|
115
136
|
does not establish execution or remove finding limitations. Kind is immutable. Updates need target=A#
|
|
116
137
|
and revision=previous-hash; changed criteria/dependencies require changed file bytes and a fresh review.
|
|
117
|
-
Ask a peer to review A# before settling.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
Ask a peer to review A# before settling. Once other artifacts pin your A# (a spec they build against),
|
|
139
|
+
its revision is a frozen contract: route clarifications to a follow-up, and republish only with text=
|
|
140
|
+
naming the change and whether behaviour is unchanged, so dependents can reattest instead of re-reviewing.
|
|
141
|
+
- Review with target=F#/A#, revision=current-hash, checked=assertions-and-method, unchecked=in-criteria-gaps,
|
|
142
|
+
limits=out-of-criteria-disclosures, verdict=supports|partially-supports|refutes|inconclusive,
|
|
143
|
+
support=tested|reproduced|derived|sourced, refs=[E#], text=observations. Judge unchecked against the
|
|
144
|
+
target's stated claim or criteria: a gap inside them keeps acceptance open until a full review covers it;
|
|
145
|
+
a limit outside them (one engine, no hardened deployment) goes in limits and is recorded on the
|
|
146
|
+
acceptance, never as a blocker. Empty unchecked means you checked the whole stated scope. A partial
|
|
147
|
+
verdict refutes nothing and opens no challenge: the acceptance names what it left unchecked. Only
|
|
148
|
+
refutes opens a challenge to the owner; send a challenge yourself when a partial check found a defect.
|
|
121
149
|
To avoid a separate attach call, review may include evidenceText=full-observation and optional
|
|
122
150
|
artifactPath instead of (or alongside) refs. Evidence and review commit atomically with separate E#/V#
|
|
123
151
|
IDs and the same independence/revision checks. Keep text a decision summary; do not repeat the raw
|
|
124
152
|
evidence in checked, text and evidenceText. The separate attach/review workflow remains available.
|
|
153
|
+
- When a target you reviewed is re-hashed without changing what you checked (an amended sentence, a
|
|
154
|
+
republish against a corrected dependency, a spec clarification), horde reattest target=F#/A#
|
|
155
|
+
revision=current-hash evidenceText=what-you-compared text=why carries your earlier review forward
|
|
156
|
+
instead of repeating it. Reattest only what is unchanged; a changed claim needs a new review.
|
|
157
|
+
- Fix wrong prose in your own finding with horde amend id=F# text=full-replacement note=why: the
|
|
158
|
+
revision changes, reviews stay pinned to what they checked, and reviewers reattest or re-review.
|
|
159
|
+
A reviewer's unchecked text or refutation that contradicts a sentence of your body is a request to
|
|
160
|
+
amend or supersede, not to leave the sentence standing. Another member corrects you with supersedes.
|
|
125
161
|
- horde resource-state target=R# resourceState=building|ready|broken|retired text=health-result ports=[port]
|
|
126
162
|
leaseSeconds=300 generation=last-generation. Ready needs refs=[health-evidence-E#]. Read collaboration first.
|
|
127
163
|
Only the lease holder mutates a lab. Expired leases require checking its state before takeover.
|
|
@@ -172,7 +208,9 @@ Protocol:
|
|
|
172
208
|
deletion is refused: reproducers under the run artifacts directory are retained as evidence.
|
|
173
209
|
4. Record each checkable environment fact once with 'horde fact' (include the command and the
|
|
174
210
|
observed result). If it extends an existing fact, pass delta-of: T# instead of restating it.
|
|
175
|
-
Read FACTS.md before re-deriving anything, and do not duplicate it.
|
|
211
|
+
Read FACTS.md before re-deriving anything, and do not duplicate it. A fact about what an open
|
|
212
|
+
idea is still auditing, or what a contested claim disputes, is recorded as provisional on it:
|
|
213
|
+
the answer may not quote it until that idea closes or the claim resolves.
|
|
176
214
|
5. Record each finding with 'horde finding'. The claimed idea id and a concern slug are required.
|
|
177
215
|
A concern is a short stable slug for the subject or issue, for example auth-token-refresh or
|
|
178
216
|
chapter-3-voice; different subjects or entry points need different concerns and separate
|
|
@@ -185,6 +223,10 @@ Protocol:
|
|
|
185
223
|
from primary documents, or speculative. If the tool
|
|
186
224
|
returns an existing F#, cite that id and file only distinct evidence with delta-of: F# and the
|
|
187
225
|
exact same concern. Prefer attach for new evidence on an existing finding; stronger follow-up reviews are welcome.
|
|
226
|
+
Before reproducing an existing F#, run 'horde findings': one with a corroborator or a full-scope
|
|
227
|
+
review is settled, and a further reproduction is refused. Bring a new vector or distinct evidence
|
|
228
|
+
as delta-of: F#, or take a different slice. Register the harness, driver, lab or corpus you build
|
|
229
|
+
as an R# before others need it, with its path in note, so they reuse it instead of rewriting it.
|
|
188
230
|
If your finding corrects or overturns an earlier finding, pass supersedes: F#. Cite R# for every run artifact. If it contradicts an earlier
|
|
189
231
|
finding, use supersedes: F#, or record a claim with assert/contest, instead of leaving the
|
|
190
232
|
correction in prose. Independent review is recorded by another member at the exact revision.
|
|
@@ -192,8 +234,9 @@ Protocol:
|
|
|
192
234
|
inconclusive reviews remain reviewed-with-gaps. Neither a review count nor full claim coverage
|
|
193
235
|
automatically establishes runtime exploitation. Keep source checks, controlled tests, and
|
|
194
236
|
end-to-end reproductions distinct; legacy verified= headings are not the scoped-review count.
|
|
195
|
-
6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it
|
|
196
|
-
|
|
237
|
+
6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it; 'done'
|
|
238
|
+
with a negative result line is refused. Contradicting evidence attached to an idea is a rule-out
|
|
239
|
+
candidate for its owner. Use 'horde drop' only to return work you are not pursuing to the open pool.
|
|
197
240
|
7. When two members actually disagree on a hypothesis, 'horde assert' it and use
|
|
198
241
|
'confirm'/'contest'/'retract' with primary evidence. Do not use CLAIMS.md for work ownership.
|
|
199
242
|
8. Post progress and dead ends in one or two lines so others do not repeat them. Re-read
|
|
@@ -207,8 +250,12 @@ Protocol:
|
|
|
207
250
|
Continue distinct claimed work while another member validates it. For a broad task,
|
|
208
251
|
one finding is never the complete answer: every current-run priority idea must first be done or
|
|
209
252
|
ruled out. Call 'horde confirm-solved' only after independent verification, or
|
|
210
|
-
'horde contest-solved' with what is missing
|
|
211
|
-
|
|
253
|
+
'horde contest-solved' with what is missing, naming the open ideas (#12, #14) that block it: the
|
|
254
|
+
proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
|
|
255
|
+
from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
|
|
256
|
+
a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
|
|
257
|
+
pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
|
|
258
|
+
the proposer or a validator about the pending answer (target=answer) is served with the blockers.
|
|
212
259
|
The parent enforces coverage before wind-down.
|
|
213
260
|
|
|
214
261
|
${COLLABORATION_PROTOCOL}
|
|
@@ -227,6 +274,7 @@ export function taskMarkdown(input: {
|
|
|
227
274
|
thinkingLevel?: string | undefined;
|
|
228
275
|
startedAt: number;
|
|
229
276
|
verifier?: string | undefined;
|
|
277
|
+
untilStop?: boolean | undefined;
|
|
230
278
|
}): string {
|
|
231
279
|
return [
|
|
232
280
|
`# Horde run ${input.id}`,
|
|
@@ -236,6 +284,7 @@ export function taskMarkdown(input: {
|
|
|
236
284
|
`Model: ${input.model ?? "parent default"}`,
|
|
237
285
|
`Thinking: ${input.thinkingLevel ?? "parent default"}`,
|
|
238
286
|
`Verifier: ${input.verifier ?? "none"}`,
|
|
287
|
+
`Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
|
|
239
288
|
`Task-SHA256: ${taskHash(input.prompt)}`,
|
|
240
289
|
"",
|
|
241
290
|
"## Prompt",
|
|
@@ -315,7 +364,7 @@ function memberElapsed(m: MemberState, now: number): string {
|
|
|
315
364
|
function memberSummarySection(
|
|
316
365
|
m: MemberState,
|
|
317
366
|
now: number,
|
|
318
|
-
stats?:
|
|
367
|
+
stats?: MemberContribution,
|
|
319
368
|
resets: MemberReset[] = [],
|
|
320
369
|
memberPosts?: Map<string, string>,
|
|
321
370
|
): string[] {
|
|
@@ -324,9 +373,17 @@ function memberSummarySection(
|
|
|
324
373
|
lines.push(`${m.turns} turns, ${m.toolCalls} tool calls, ${tokens}, ${memberElapsed(m, now)}`);
|
|
325
374
|
if (stats) {
|
|
326
375
|
lines.push(
|
|
327
|
-
`${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped
|
|
376
|
+
`${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped: ${stats.fullScope} full, ` +
|
|
377
|
+
`${stats.reviewed - stats.fullScope} with unchecked scope), ${stats.verified} legacy verification labels, ` +
|
|
328
378
|
`${stats.ideasClosed} claimed idea(s) closed`,
|
|
329
379
|
);
|
|
380
|
+
// The coverage sentence comes from the review records, not from the member's prose: a
|
|
381
|
+
// verifier's "all work is verified" over twelve reviews with unchecked scope stated zero.
|
|
382
|
+
if (stats.verifier) {
|
|
383
|
+
const total = stats.reviewed + stats.unreviewed.length;
|
|
384
|
+
const owed = stats.unreviewed.length ? `; unreviewed: ${stats.unreviewed.map((id) => `F${id}`).join(", ")}` : "";
|
|
385
|
+
lines.push(`verifier coverage from review records: ${stats.reviewed} of ${total} other members' current findings reviewed, ${stats.fullScope} at full scope${owed}`);
|
|
386
|
+
}
|
|
330
387
|
}
|
|
331
388
|
if (resets.length > 0) {
|
|
332
389
|
lines.push(`resets: ${resets.length} (${resets.map((reset) => reset.reason).join("; ")})`);
|
|
@@ -411,7 +468,7 @@ export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()):
|
|
|
411
468
|
const ideas = run.ideas ?? [];
|
|
412
469
|
const haveLedger = findings.length > 0 || ideas.length > 0;
|
|
413
470
|
for (const m of run.members) {
|
|
414
|
-
const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews) : undefined;
|
|
471
|
+
const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews, m.name === run.verifier) : undefined;
|
|
415
472
|
lines.push(...memberSummarySection(m, now, stats, resetsOf(m.name, run.resets), run.memberPosts));
|
|
416
473
|
}
|
|
417
474
|
return lines.join("\n");
|
package/src/run/completions.ts
CHANGED
|
@@ -25,7 +25,12 @@ const NO_VERIFIER_COMPLETION = {
|
|
|
25
25
|
description: "do not assign the verifier role to the last member of a 3+ member run",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const
|
|
28
|
+
const UNTIL_STOP_COMPLETION = {
|
|
29
|
+
value: "--until-stop ",
|
|
30
|
+
description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION];
|
|
29
34
|
|
|
30
35
|
/** Argument completions that are not member targets. */
|
|
31
36
|
const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
|
package/src/run/finalize.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { clusterFindings } from "../ledger/clusters.js";
|
|
|
20
20
|
import { listFacts } from "../ledger/facts.js";
|
|
21
21
|
import type { FactEntry } from "../ledger/facts.js";
|
|
22
22
|
import { readHordeFile } from "../ledger/files.js";
|
|
23
|
-
import { listFindingsForRun, parseFindings } from "../ledger/findings.js";
|
|
23
|
+
import { listFindingsForRun, parseFindings, supersededCitations } from "../ledger/findings.js";
|
|
24
24
|
import type { FindingEntry } from "../ledger/findings.js";
|
|
25
25
|
import { countIdeas, parseIdeas, reconcileOwnedIdeas } from "../ledger/ideas.js";
|
|
26
26
|
import type { Idea } from "../ledger/ideas.js";
|
|
@@ -109,6 +109,11 @@ function settleSolved(run: HordeRun): SettledAnswer | undefined {
|
|
|
109
109
|
) {
|
|
110
110
|
run.solvedValidatedBy = solvedState.confirm.member;
|
|
111
111
|
}
|
|
112
|
+
// `--until-stop`: the last banked answer stands unless a later proposal was validated too.
|
|
113
|
+
if (run.lastRound && !(run.solved && run.solvedValidatedBy)) {
|
|
114
|
+
run.solved = run.lastRound.proposal;
|
|
115
|
+
run.solvedValidatedBy = run.lastRound.validator;
|
|
116
|
+
}
|
|
112
117
|
if (run.solved) {
|
|
113
118
|
const status = run.solvedValidatedBy
|
|
114
119
|
? `validated by ${run.solvedValidatedBy}`
|
|
@@ -132,12 +137,6 @@ interface SettledAnswer {
|
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
/** Findings the proposal names that a later finding corrected; labeled, not re-versioned. */
|
|
135
|
-
export function supersededCitations(text: string, findings: FindingEntry[]): string[] {
|
|
136
|
-
return findings
|
|
137
|
-
.filter((finding) => finding.supersededBy !== undefined && new RegExp(`\\bF${finding.id}\\b`).test(text))
|
|
138
|
-
.map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
140
|
/**
|
|
142
141
|
* The proposal body is the member's verbatim text and can predate later challenges and corrections.
|
|
143
142
|
* The status names the blockers still open at freeze and the findings a later finding superseded,
|
package/src/run/launch.ts
CHANGED
|
@@ -194,7 +194,7 @@ async function launchRun(
|
|
|
194
194
|
try {
|
|
195
195
|
await seedRun(run);
|
|
196
196
|
await recordLifecycle(run, "run-start", {
|
|
197
|
-
count: run.count, model: run.model, verifier: run.verifier, plan: run.plan,
|
|
197
|
+
count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
|
|
198
198
|
roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
|
|
199
199
|
});
|
|
200
200
|
await recordPhase(run, "discovery", "start");
|
package/src/run/repair.ts
CHANGED
|
@@ -13,9 +13,12 @@ import { scheduleCollaboration, stopReason } from "./schedule.js";
|
|
|
13
13
|
import { spawnOne } from "./supervise.js";
|
|
14
14
|
import type { RunLaunch } from "./supervise.js";
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Blockers and independent checks take precedence over administrative exchanges. A request to
|
|
18
|
+
* validate the pending answer decides the run's outcome, so it ranks with the blockers.
|
|
19
|
+
*/
|
|
17
20
|
function repairPriority(message: PeerMessage): number {
|
|
18
|
-
if (message.blocking) return 0;
|
|
21
|
+
if (message.blocking || message.target === "answer") return 0;
|
|
19
22
|
if (message.kind === "review-request" && /^[AF]\d+$/.test(message.target)) return 1;
|
|
20
23
|
if (message.state === "responded") return 2;
|
|
21
24
|
return 3;
|
|
@@ -33,7 +36,7 @@ function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
|
|
|
33
36
|
* three reviews demands three, so its share is sized to the work rather than to the member.
|
|
34
37
|
*/
|
|
35
38
|
export function repairDemand(queue: PeerMessage[]): number {
|
|
36
|
-
const checks = queue.filter((m) => m.blocking || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
|
|
39
|
+
const checks = queue.filter((m) => m.blocking || m.target === "answer" || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
|
|
37
40
|
return Math.max(1, checks);
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -135,8 +138,12 @@ interface FirstTurnCost {
|
|
|
135
138
|
cacheWrite: number;
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
141
|
+
/**
|
|
142
|
+
* A finished worker receives a fresh, bounded attempt with a precise evidence handoff. The time
|
|
143
|
+
* share is the allowance recorded and the deadline is derived from it once, so the record cannot
|
|
144
|
+
* read a millisecond under the floor the share was raised to.
|
|
145
|
+
*/
|
|
146
|
+
async function repairMember(launch: RunLaunch, member: MemberState, ms: number, limits: RepairLimits, floors: RepairFloors): Promise<void> {
|
|
140
147
|
const { run } = launch;
|
|
141
148
|
const ctx = { hordeDir: run.hordeDir, runId: run.id, actor: member.name };
|
|
142
149
|
const queue = queueFor(launch, member);
|
|
@@ -146,7 +153,8 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
|
|
|
146
153
|
member.repairs = (member.repairs ?? 0) + 1;
|
|
147
154
|
member.repairStopReason = undefined;
|
|
148
155
|
member.attempt = randomUUID();
|
|
149
|
-
const
|
|
156
|
+
const deadline = Date.now() + ms;
|
|
157
|
+
const allowance = { ...limits, ms };
|
|
150
158
|
let firstTurn: FirstTurnCost | undefined;
|
|
151
159
|
member.resetNote = `Repair attempt ${member.repairs}. Discovery is closed. Budget: about ${allowance.tokens} input+output tokens, ${allowance.turns} turns and ${allowance.ms}ms. ` +
|
|
152
160
|
`Read horde inbox and referenced evidence. Work in this priority order: ${queue.map((m) => `${m.id} ${m.target} (${m.state})`).join(", ")}. ` +
|
|
@@ -264,7 +272,7 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
|
|
|
264
272
|
}
|
|
265
273
|
await begin(budget);
|
|
266
274
|
served.add(next.member.name);
|
|
267
|
-
await repairMember(launch, next.member,
|
|
275
|
+
await repairMember(launch, next.member, ms, { tokens, turns: budget.turns }, floors);
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
278
|
}
|