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/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. */
|
|
@@ -75,12 +77,30 @@ export interface MemberReset {
|
|
|
75
77
|
tokens: number;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/** Another horde of the same swarm, whose ledgers a member may read but never write. */
|
|
81
|
+
export interface SiblingHorde {
|
|
82
|
+
/** `h1`, `h2`, ... */
|
|
83
|
+
label: string;
|
|
84
|
+
hordeDir: string;
|
|
85
|
+
/** That horde's workstream, when the swarm partitioned the task. */
|
|
86
|
+
slice?: string | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
export interface MemberRunInfo {
|
|
79
90
|
id: string;
|
|
80
91
|
hordeDir: string;
|
|
81
92
|
runDir: string;
|
|
82
93
|
prompt: string;
|
|
83
94
|
count: number;
|
|
95
|
+
/** True when started with `--until-stop`: the run continues until the user stops it. */
|
|
96
|
+
untilStop?: boolean | undefined;
|
|
97
|
+
/** 1-based horde index in the swarm and the swarm's size; a single run is horde 1 of 1. */
|
|
98
|
+
horde?: number | undefined;
|
|
99
|
+
hordes?: number | undefined;
|
|
100
|
+
/** This horde's workstream, told to every member, when the swarm partitioned the task. */
|
|
101
|
+
slice?: string | undefined;
|
|
102
|
+
/** The other hordes of the swarm. */
|
|
103
|
+
siblings?: SiblingHorde[] | undefined;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
interface SpawnMemberInput {
|
|
@@ -283,11 +303,31 @@ export function memberSessionDir(runDir: string, member: string): string {
|
|
|
283
303
|
return path.join(runDir, "sessions", member);
|
|
284
304
|
}
|
|
285
305
|
|
|
286
|
-
/**
|
|
287
|
-
|
|
306
|
+
/** What an `--until-stop` member does instead of stopping when the board runs dry. */
|
|
307
|
+
export const UNTIL_STOP_NOTE =
|
|
308
|
+
"This run continues until the user stops it. When nothing worthwhile remains on the board, add " +
|
|
309
|
+
"ideas that extend, stress-test, or deepen the work and pursue them instead of stopping. Propose " +
|
|
310
|
+
"SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
|
|
311
|
+
"materially better one exists.";
|
|
312
|
+
|
|
313
|
+
/** What a member of a partitioned swarm is told about its horde's workstream. */
|
|
314
|
+
export function sliceNote(run: MemberRunInfo): string | undefined {
|
|
315
|
+
if (!run.slice || !run.horde || !run.hordes || run.hordes < 2) return undefined;
|
|
316
|
+
return (
|
|
317
|
+
`Your horde is h${run.horde} of ${run.hordes} hordes working the same task in parallel, each in its own ` +
|
|
318
|
+
`ledgers. Your horde owns workstream ${run.horde}: "${run.slice}". Work only on your horde's workstream; ` +
|
|
319
|
+
"do not duplicate another horde's."
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
|
|
324
|
+
function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
|
|
288
325
|
const parts = [`You are ${member.name}.`];
|
|
326
|
+
const slice = sliceNote(run);
|
|
327
|
+
if (slice) parts.push(slice);
|
|
289
328
|
if (role) parts.push(role);
|
|
290
329
|
if (member.resetNote) parts.push(member.resetNote);
|
|
330
|
+
if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
|
|
291
331
|
return parts.join(" ");
|
|
292
332
|
}
|
|
293
333
|
|
|
@@ -297,7 +337,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
|
|
|
297
337
|
"--mode", "json", "-p",
|
|
298
338
|
"--session-dir", memberSessionDir(run.runDir, member.name),
|
|
299
339
|
"--append-system-prompt", path.join(run.runDir, "SYSTEM.md"),
|
|
300
|
-
"--append-system-prompt", identityLine(member, input.role),
|
|
340
|
+
"--append-system-prompt", identityLine(run, member, input.role),
|
|
301
341
|
];
|
|
302
342
|
if (input.model) args.push("--model", `${input.model.provider}/${input.model.id}`);
|
|
303
343
|
if (input.thinkingLevel) args.push("--thinking", input.thinkingLevel);
|
|
@@ -307,6 +347,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
|
|
|
307
347
|
}
|
|
308
348
|
|
|
309
349
|
export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
|
|
350
|
+
const siblings = input.run.siblings ?? [];
|
|
310
351
|
return childEnv({
|
|
311
352
|
PI_HORDE_MEMBER: "1",
|
|
312
353
|
PI_HORDE_DIR: input.run.hordeDir,
|
|
@@ -314,9 +355,27 @@ export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
|
|
|
314
355
|
PI_HORDE_RUN: input.run.id,
|
|
315
356
|
PI_HORDE_ROLE: input.role ?? "",
|
|
316
357
|
PI_HORDE_ATTEMPT: input.member.attempt ??= randomUUID(),
|
|
358
|
+
PI_HORDE_SIBLINGS: siblings.length > 0 ? JSON.stringify(siblings.map((s) => ({ label: s.label, hordeDir: s.hordeDir }))) : "",
|
|
317
359
|
});
|
|
318
360
|
}
|
|
319
361
|
|
|
362
|
+
/** The sibling hordes a member process was given, from `PI_HORDE_SIBLINGS`; empty outside a swarm. */
|
|
363
|
+
export function parseSiblings(raw: string | undefined): SiblingHorde[] {
|
|
364
|
+
if (!raw) return [];
|
|
365
|
+
try {
|
|
366
|
+
const items = JSON.parse(raw) as unknown;
|
|
367
|
+
if (!Array.isArray(items)) return [];
|
|
368
|
+
return items
|
|
369
|
+
.filter((item): item is { label: string; hordeDir: string } =>
|
|
370
|
+
typeof item === "object" && item !== null &&
|
|
371
|
+
typeof (item as { label?: unknown }).label === "string" &&
|
|
372
|
+
typeof (item as { hordeDir?: unknown }).hordeDir === "string")
|
|
373
|
+
.map((item) => ({ label: item.label, hordeDir: item.hordeDir }));
|
|
374
|
+
} catch {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
320
379
|
export function splitNdjson(buffer: string, onLine: (line: string) => void): string {
|
|
321
380
|
const lines = buffer.split("\n");
|
|
322
381
|
const remainder = lines.pop() ?? "";
|
package/src/prompt.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { PeerMessage, ReviewRecord } from "./ledger/collaboration.js";
|
|
|
9
9
|
import type { Idea } from "./ledger/ideas.js";
|
|
10
10
|
import type { ResourceEntry } from "./ledger/resources.js";
|
|
11
11
|
import type { SolvedSignal } from "./ledger/run-signals.js";
|
|
12
|
-
import type { MemberReset, MemberState } from "./member.js";
|
|
12
|
+
import type { MemberReset, MemberState, SiblingHorde } from "./member.js";
|
|
13
13
|
|
|
14
14
|
/** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
|
|
15
15
|
export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
|
|
@@ -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.
|
|
@@ -133,7 +169,26 @@ Collaboration loop:
|
|
|
133
169
|
attempt after discovery exits. Repair attempts must address assigned feedback only.
|
|
134
170
|
`;
|
|
135
171
|
|
|
136
|
-
|
|
172
|
+
/** What a member of a swarm is told about the other hordes: shared read, local write. */
|
|
173
|
+
function siblingsSection(siblings: SiblingHorde[]): string {
|
|
174
|
+
if (siblings.length === 0) return "";
|
|
175
|
+
const lines = siblings.map(
|
|
176
|
+
(sibling) => `- ${sibling.label}: ${sibling.hordeDir}/${sibling.slice ? ` (workstream: ${sibling.slice})` : ""}`,
|
|
177
|
+
);
|
|
178
|
+
return `
|
|
179
|
+
Your horde is one of ${siblings.length + 1} hordes working the same task in parallel, each with its
|
|
180
|
+
own ledgers. The other hordes' directories are read-only for you:
|
|
181
|
+
|
|
182
|
+
${lines.join("\n")}
|
|
183
|
+
|
|
184
|
+
'horde facts' and 'horde resources' include their entries tagged [hN], and 'horde list' includes
|
|
185
|
+
their done and ruled-out ideas, so a fact, a rule-out, or a resource another horde recorded is not
|
|
186
|
+
re-derived here; reuse a sibling's resource by its path. Every write goes to your own horde's
|
|
187
|
+
ledgers; never write to a sibling's directory, and never claim or cite a sibling's ideas as yours.
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function memberSystemPrompt(input: { hordeDir: string; count: number; siblings?: SiblingHorde[] | undefined }): string {
|
|
137
192
|
const count = input.count;
|
|
138
193
|
return `You are one member of a horde of ${count} agents. Every member received the same task
|
|
139
194
|
and works in parallel in this repository with its own separate context. Coordinate through the
|
|
@@ -152,9 +207,9 @@ horde tool. Durable run events retain messages, evidence, reviews and revisions;
|
|
|
152
207
|
- PLAN.md workstream partition and your assigned layer, when the run was started with plan.
|
|
153
208
|
|
|
154
209
|
Read them with the read tool. Write them ONLY with the horde tool; never edit them directly.
|
|
155
|
-
Put reproduction snippets under .
|
|
156
|
-
survives finalization and line references do not drift.
|
|
157
|
-
|
|
210
|
+
Put reproduction snippets under ${input.hordeDir}/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so
|
|
211
|
+
evidence survives finalization and line references do not drift.
|
|
212
|
+
${siblingsSection(input.siblings ?? [])}
|
|
158
213
|
Protocol:
|
|
159
214
|
1. Run 'horde list', 'horde findings', 'horde facts', and read BOARD.md before planning.
|
|
160
215
|
2. Prefer the shared queue. Add an idea only for an uncovered question or concrete evidence gap.
|
|
@@ -172,7 +227,9 @@ Protocol:
|
|
|
172
227
|
deletion is refused: reproducers under the run artifacts directory are retained as evidence.
|
|
173
228
|
4. Record each checkable environment fact once with 'horde fact' (include the command and the
|
|
174
229
|
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.
|
|
230
|
+
Read FACTS.md before re-deriving anything, and do not duplicate it. A fact about what an open
|
|
231
|
+
idea is still auditing, or what a contested claim disputes, is recorded as provisional on it:
|
|
232
|
+
the answer may not quote it until that idea closes or the claim resolves.
|
|
176
233
|
5. Record each finding with 'horde finding'. The claimed idea id and a concern slug are required.
|
|
177
234
|
A concern is a short stable slug for the subject or issue, for example auth-token-refresh or
|
|
178
235
|
chapter-3-voice; different subjects or entry points need different concerns and separate
|
|
@@ -185,6 +242,10 @@ Protocol:
|
|
|
185
242
|
from primary documents, or speculative. If the tool
|
|
186
243
|
returns an existing F#, cite that id and file only distinct evidence with delta-of: F# and the
|
|
187
244
|
exact same concern. Prefer attach for new evidence on an existing finding; stronger follow-up reviews are welcome.
|
|
245
|
+
Before reproducing an existing F#, run 'horde findings': one with a corroborator or a full-scope
|
|
246
|
+
review is settled, and a further reproduction is refused. Bring a new vector or distinct evidence
|
|
247
|
+
as delta-of: F#, or take a different slice. Register the harness, driver, lab or corpus you build
|
|
248
|
+
as an R# before others need it, with its path in note, so they reuse it instead of rewriting it.
|
|
188
249
|
If your finding corrects or overturns an earlier finding, pass supersedes: F#. Cite R# for every run artifact. If it contradicts an earlier
|
|
189
250
|
finding, use supersedes: F#, or record a claim with assert/contest, instead of leaving the
|
|
190
251
|
correction in prose. Independent review is recorded by another member at the exact revision.
|
|
@@ -192,8 +253,9 @@ Protocol:
|
|
|
192
253
|
inconclusive reviews remain reviewed-with-gaps. Neither a review count nor full claim coverage
|
|
193
254
|
automatically establishes runtime exploitation. Keep source checks, controlled tests, and
|
|
194
255
|
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
|
-
|
|
256
|
+
6. When a lead is a confirmed negative, use 'horde rule-out' so later runs do not retry it; 'done'
|
|
257
|
+
with a negative result line is refused. Contradicting evidence attached to an idea is a rule-out
|
|
258
|
+
candidate for its owner. Use 'horde drop' only to return work you are not pursuing to the open pool.
|
|
197
259
|
7. When two members actually disagree on a hypothesis, 'horde assert' it and use
|
|
198
260
|
'confirm'/'contest'/'retract' with primary evidence. Do not use CLAIMS.md for work ownership.
|
|
199
261
|
8. Post progress and dead ends in one or two lines so others do not repeat them. Re-read
|
|
@@ -207,8 +269,12 @@ Protocol:
|
|
|
207
269
|
Continue distinct claimed work while another member validates it. For a broad task,
|
|
208
270
|
one finding is never the complete answer: every current-run priority idea must first be done or
|
|
209
271
|
ruled out. Call 'horde confirm-solved' only after independent verification, or
|
|
210
|
-
'horde contest-solved' with what is missing
|
|
211
|
-
|
|
272
|
+
'horde contest-solved' with what is missing, naming the open ideas (#12, #14) that block it: the
|
|
273
|
+
proposal reopens for validation once every idea it named is done or ruled out. Pass the revision
|
|
274
|
+
from ANSWER.md or 'horde inspect' target=answer to either action, so stale reviews cannot approve
|
|
275
|
+
a newer proposal. An answer that cites a superseded F# or quotes a provisional T# is refused, and a
|
|
276
|
+
pending proposal is withdrawn for re-proposal when a finding it cites is superseded. A request to
|
|
277
|
+
the proposer or a validator about the pending answer (target=answer) is served with the blockers.
|
|
212
278
|
The parent enforces coverage before wind-down.
|
|
213
279
|
|
|
214
280
|
${COLLABORATION_PROTOCOL}
|
|
@@ -227,7 +293,11 @@ export function taskMarkdown(input: {
|
|
|
227
293
|
thinkingLevel?: string | undefined;
|
|
228
294
|
startedAt: number;
|
|
229
295
|
verifier?: string | undefined;
|
|
296
|
+
untilStop?: boolean | undefined;
|
|
297
|
+
/** Set for a horde of a larger swarm: which horde this is and its workstream. */
|
|
298
|
+
swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
|
|
230
299
|
}): string {
|
|
300
|
+
const swarm = input.swarm;
|
|
231
301
|
return [
|
|
232
302
|
`# Horde run ${input.id}`,
|
|
233
303
|
"",
|
|
@@ -236,6 +306,9 @@ export function taskMarkdown(input: {
|
|
|
236
306
|
`Model: ${input.model ?? "parent default"}`,
|
|
237
307
|
`Thinking: ${input.thinkingLevel ?? "parent default"}`,
|
|
238
308
|
`Verifier: ${input.verifier ?? "none"}`,
|
|
309
|
+
`Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
|
|
310
|
+
...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
|
|
311
|
+
...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
|
|
239
312
|
`Task-SHA256: ${taskHash(input.prompt)}`,
|
|
240
313
|
"",
|
|
241
314
|
"## Prompt",
|
|
@@ -315,7 +388,7 @@ function memberElapsed(m: MemberState, now: number): string {
|
|
|
315
388
|
function memberSummarySection(
|
|
316
389
|
m: MemberState,
|
|
317
390
|
now: number,
|
|
318
|
-
stats?:
|
|
391
|
+
stats?: MemberContribution,
|
|
319
392
|
resets: MemberReset[] = [],
|
|
320
393
|
memberPosts?: Map<string, string>,
|
|
321
394
|
): string[] {
|
|
@@ -324,9 +397,17 @@ function memberSummarySection(
|
|
|
324
397
|
lines.push(`${m.turns} turns, ${m.toolCalls} tool calls, ${tokens}, ${memberElapsed(m, now)}`);
|
|
325
398
|
if (stats) {
|
|
326
399
|
lines.push(
|
|
327
|
-
`${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped
|
|
400
|
+
`${stats.findings} findings filed, ${stats.reviewed} independently reviewed (scoped: ${stats.fullScope} full, ` +
|
|
401
|
+
`${stats.reviewed - stats.fullScope} with unchecked scope), ${stats.verified} legacy verification labels, ` +
|
|
328
402
|
`${stats.ideasClosed} claimed idea(s) closed`,
|
|
329
403
|
);
|
|
404
|
+
// The coverage sentence comes from the review records, not from the member's prose: a
|
|
405
|
+
// verifier's "all work is verified" over twelve reviews with unchecked scope stated zero.
|
|
406
|
+
if (stats.verifier) {
|
|
407
|
+
const total = stats.reviewed + stats.unreviewed.length;
|
|
408
|
+
const owed = stats.unreviewed.length ? `; unreviewed: ${stats.unreviewed.map((id) => `F${id}`).join(", ")}` : "";
|
|
409
|
+
lines.push(`verifier coverage from review records: ${stats.reviewed} of ${total} other members' current findings reviewed, ${stats.fullScope} at full scope${owed}`);
|
|
410
|
+
}
|
|
330
411
|
}
|
|
331
412
|
if (resets.length > 0) {
|
|
332
413
|
lines.push(`resets: ${resets.length} (${resets.map((reset) => reset.reason).join("; ")})`);
|
|
@@ -411,7 +492,7 @@ export function summaryMarkdown(run: RunSummaryInput, now: number = Date.now()):
|
|
|
411
492
|
const ideas = run.ideas ?? [];
|
|
412
493
|
const haveLedger = findings.length > 0 || ideas.length > 0;
|
|
413
494
|
for (const m of run.members) {
|
|
414
|
-
const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews) : undefined;
|
|
495
|
+
const stats = haveLedger ? memberContribution(m.name, findings, ideas, run.scopedReviews, m.name === run.verifier) : undefined;
|
|
415
496
|
lines.push(...memberSummarySection(m, now, stats, resetsOf(m.name, run.resets), run.memberPosts));
|
|
416
497
|
}
|
|
417
498
|
return lines.join("\n");
|