agent-horde 0.1.3 → 0.1.4

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 CHANGED
@@ -88,7 +88,7 @@ pi install npm:agent-horde
88
88
  Or from the repository, pinned to a tag:
89
89
 
90
90
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.3
91
+ pi install git:github.com/eliyastein/agent-horde@v0.1.4
92
92
  ```
93
93
 
94
94
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
package/docs/REFERENCE.md CHANGED
@@ -37,9 +37,10 @@ synthesize it.
37
37
  a fresh session takes its name, up to `PI_HORDE_RESET_CAP` times.
38
38
  3. **Repair.** After discovery ends normally, finished members get fresh, targeted sessions to
39
39
  answer open blockers, review requests, and responses, within the `PI_HORDE_REPAIR_*` budgets.
40
- The default totals follow the queue, so every queued member can afford a cold first turn; the
41
- phase start records the budget and `repair-end` records each attempt's turns and first-turn
42
- cost. A user stop skips this phase.
40
+ The default totals follow the queues' demand (one floor per queue plus one per further review
41
+ request or blocking challenge), each attempt's share is cut in proportion to its demand, and
42
+ every attempt gets at least a floor; the phase start records the budget, `repair-start` the
43
+ attempt's demand, and `repair-end` its turns and first-turn cost. A user stop skips this phase.
43
44
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
44
45
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
45
46
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -181,6 +182,9 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
181
182
  request, and the requester then resolves it. A challenger can withdraw its own blocking
182
183
  challenge on an F# or A# before any response by recording a supporting review of the current
183
184
  revision and resolving with evidence on it; limits that review discloses remain acceptance debt.
185
+ The blocking challenge a `partially-supports` review raises is cleared when a different
186
+ non-owner member records a full-scope `supports` review of the same revision; the resolution
187
+ names both reviews. Acceptance names an open blocker as `blocked by Q#`.
184
188
  - **Inbox.** Targeted messages are inserted at turn boundaries, for at most
185
189
  `PI_HORDE_INBOX_TURNS` extra turns per session. A recipient answers with `respond`; `ack` is an
186
190
  optional first step that signals intent. Delivery is a context insertion, not
@@ -240,8 +244,8 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
240
244
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
241
245
  | `PI_HORDE_ALLOW_UNVERIFIED` | unset | `1` accepts a validated answer with unverified findings |
242
246
  | `PI_HORDE_REPAIR_ROUNDS` | 4 | post-discovery repair rounds (max 6; `0` disables) |
243
- | `PI_HORDE_REPAIR_MS` | queue-sized | total repair time: one `PI_HORDE_REPAIR_MIN_MS` per queued member (max 4) plus two recheck shares; a number here is a flat total |
244
- | `PI_HORDE_REPAIR_TOKENS` | queue-sized | total repair tokens: one `PI_HORDE_REPAIR_MIN_TOKENS` per queued member (max 4) plus two recheck shares; a number here is a flat total |
247
+ | `PI_HORDE_REPAIR_MS` | demand-sized | total repair time: one `PI_HORDE_REPAIR_MIN_MS` per demand unit (max 6) plus two recheck shares; a number here is a flat total |
248
+ | `PI_HORDE_REPAIR_TOKENS` | demand-sized | total repair tokens: one `PI_HORDE_REPAIR_MIN_TOKENS` per demand unit (max 6) plus two recheck shares; a number here is a flat total |
245
249
  | `PI_HORDE_REPAIR_MIN_TOKENS` | 40000 | token floor per attempt; below it scheduling stops with a `repair-skipped` event |
246
250
  | `PI_HORDE_REPAIR_MIN_MS` | 60000 | time floor per attempt; the clock is split only among attempts the token budget can fund |
247
251
  | `PI_HORDE_REPAIR_TURNS` | 40 | turn cap per attempt; an attempt still reading its inbox at the cap is stopped with `turns` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/citations.ts CHANGED
@@ -73,7 +73,10 @@ export function citedDataFiles(text: string): string[] {
73
73
  return [...out];
74
74
  }
75
75
 
76
- /** Bounded search for a file by name under a directory; artifact dirs are small. */
76
+ /** Trees a basename search never descends into: run data, dependencies and version control. */
77
+ const UNSEARCHED_DIRS = new Set([".horde", "node_modules", ".git"]);
78
+
79
+ /** Bounded search for a file by name under a directory; artifact dirs and source trees are shallow. */
77
80
  function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
78
81
  let entries: fs.Dirent[];
79
82
  try {
@@ -83,7 +86,7 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
83
86
  }
84
87
  for (const entry of entries) {
85
88
  if (entry.isFile() && entry.name === name) return true;
86
- if (entry.isDirectory() && depth > 0 && fileNamedUnder(path.join(dir, entry.name), name, depth - 1)) {
89
+ if (entry.isDirectory() && depth > 0 && !UNSEARCHED_DIRS.has(entry.name) && fileNamedUnder(path.join(dir, entry.name), name, depth - 1)) {
87
90
  return true;
88
91
  }
89
92
  }
@@ -94,7 +97,8 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
94
97
  * True when a cited file cannot be resolved to an existing file on disk. Paths
95
98
  * are tried against the repo root, the run directory, its artifacts directory,
96
99
  * and the directory of the citing file. Because generated artifacts usually cite
97
- * retained files by a relative name, a basename match anywhere in this run's tree
100
+ * retained files by a relative name, and a deliverable cites the reviewed project's
101
+ * own files by bare name, a basename match in this run's tree or in the project tree
98
102
  * also counts, which keeps the warning precise instead of noisy.
99
103
  */
100
104
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
@@ -110,7 +114,7 @@ function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean
110
114
  }
111
115
  }
112
116
  const base = ref.replace(/^\.\//, "").split("/").pop() ?? ref;
113
- return !fileNamedUnder(run.runDir, base);
117
+ return !fileNamedUnder(run.runDir, base) && !fileNamedUnder(run.cwd, base);
114
118
  }
115
119
 
116
120
  export function resourceCitations(text: string): number[] {
@@ -229,13 +233,17 @@ function citedWithoutLabel(text: string, id: number): boolean {
229
233
  return citing.length > 0 && !citing.some((paragraph) => UNREVIEWED_LABEL.test(paragraph));
230
234
  }
231
235
 
236
+ /** A sentence ends at `.`, `!` or `?`, possibly inside closing emphasis or brackets, before a capitalised start. */
237
+ const SENTENCE_BREAK = /(?<=[.!?][*_)\]"']*)\s+(?=[A-Z"'([*_`])/;
238
+
232
239
  /**
233
- * The units a review citation is judged in: paragraphs, except that each list item and table row
240
+ * The units a review citation is judged in: sentences, except that each list item and table row
234
241
  * stands alone. A bullet that cites reviews as the source of a limitation does not claim them as
235
- * support for a finding named two bullets up.
242
+ * support for a finding named two bullets up, and a sentence that cites a document's review does
243
+ * not claim it for the finding named in the sentence before.
236
244
  */
237
245
  function citationUnits(text: string): string[] {
238
- return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : [paragraph]));
246
+ return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : paragraph.split(SENTENCE_BREAK)));
239
247
  }
240
248
 
241
249
  /**
@@ -51,6 +51,10 @@ function artifactProblem(hordeDir: string, runId: string, state: CollaborationSt
51
51
  const file = fs.realpathSync(path.join(hordeDir, "runs", runId, artifact.path));
52
52
  if (!file.startsWith(root + path.sep) || hashText(fs.readFileSync(file)) !== artifact.hash) return "file changed after publication";
53
53
  } catch { return "published file unavailable"; }
54
+ // A blocker is named as such: the acceptance text is read by members and the deliverable, and
55
+ // "needs a review" sent them after a review that already existed.
56
+ const blocker = blockingMessages(state).find((m) => m.target === artifact.id);
57
+ if (blocker) return `blocked by ${blocker.id} (${blocker.state} blocking ${blocker.kind} from ${blocker.sender})`;
54
58
  return artifactAccepted(state, artifact) ? undefined : artifact.kind === "document"
55
59
  ? "needs a full independent review against document criteria"
56
60
  : "needs a full independent tested/reproduced review";
@@ -114,9 +114,10 @@ export function clusterFindings(entries: FindingEntry[]): FindingCluster[] {
114
114
  return drafts
115
115
  .filter((draft) => draft.findings.length > 0)
116
116
  .map((draft) => {
117
- // A cluster is represented by its root finding: a non-delta with the lowest id.
117
+ // A cluster is represented by its root finding: a current non-delta with the lowest id. A
118
+ // superseded root would file the whole cluster, successor included, under superseded.
118
119
  const representative = [...draft.findings].sort((a, b) => {
119
- const rank = (finding: FindingEntry): number => (finding.deltaOf === undefined ? 0 : 1);
120
+ const rank = (finding: FindingEntry): number => (finding.supersededBy === undefined ? 0 : 2) + (finding.deltaOf === undefined ? 0 : 1);
120
121
  return rank(a) - rank(b) || a.id - b.id;
121
122
  })[0]!;
122
123
  const members: string[] = [];
@@ -100,10 +100,23 @@ export function projectCollaboration(events: HordeEvent[]): CollaborationState {
100
100
  state.resources = state.resources.filter((r) => r.target !== contract.target).concat(contract);
101
101
  }
102
102
  if (p.transition) applyTransition(state, event);
103
+ if (Array.isArray(p.clearances)) applyClearances(state, event);
103
104
  }
104
105
  return state;
105
106
  }
106
107
 
108
+ /** A full-scope review that covers a partial review's unchecked scope resolves the challenge that review raised. */
109
+ function applyClearances(state: CollaborationState, event: HordeEvent): void {
110
+ for (const clearance of event.payload.clearances as Array<{ id: string; text: string; refs: string[] }>) {
111
+ const message = state.messages.find((m) => m.id === clearance.id);
112
+ if (!message || message.state === "resolved") continue;
113
+ message.state = "resolved";
114
+ message.resolution = clearance.text;
115
+ message.resolutionEvidence = clearance.refs;
116
+ message.updatedAt = event.at;
117
+ }
118
+ }
119
+
107
120
  function applyTransition(state: CollaborationState, event: HordeEvent): void {
108
121
  const t = event.payload.transition as { id: string; state: MessageState; text?: string; revision?: string; responseTarget?: string; attempt?: string };
109
122
  const message = state.messages.find((m) => m.id === t.id);
@@ -3,17 +3,18 @@ import type { HordeEvent } from "./events.js";
3
3
  export interface SolvedConfirmations {
4
4
  /** Members whose confirm-solved was the one credited for a proposal revision. */
5
5
  credited: string[];
6
- /** Members whose confirm-solved arrived after the credited one and changed nothing. */
7
- redundant: string[];
6
+ /** Members who confirmed the same proposal after the credited one; their text is in the event. */
7
+ additional: string[];
8
8
  }
9
9
 
10
- /** The report shows how many members spent a confirmation on an already validated proposal. */
10
+ /** The report names every member that confirmed the proposal, not only the one credited. */
11
11
  export function solvedConfirmations(events: HordeEvent[]): SolvedConfirmations {
12
- const out: SolvedConfirmations = { credited: [], redundant: [] };
12
+ const out: SolvedConfirmations = { credited: [], additional: [] };
13
13
  for (const event of events) {
14
- if (event.action !== "confirm-solved") continue;
15
- if (event.outcome === "applied") out.credited.push(event.actor);
16
- else if (event.outcome === "noop") out.redundant.push(event.actor);
14
+ if (event.action !== "confirm-solved" || event.outcome === "rejected" || event.outcome === "intent") continue;
15
+ const result = event.payload.result as { confirmation?: unknown } | undefined;
16
+ if (event.outcome === "applied" && result?.confirmation !== "additional") out.credited.push(event.actor);
17
+ else out.additional.push(event.actor);
17
18
  }
18
19
  return out;
19
20
  }
@@ -215,18 +215,26 @@ export async function finishIdea(
215
215
  });
216
216
  }
217
217
 
218
+ /**
219
+ * Release a claimed idea back to the pool, or retire an unclaimed one you added. `author` is the
220
+ * member whose write created the idea, when the caller knows it; an unclaimed idea is dropped only
221
+ * by its author, so folding your own duplicate does not require claiming it first.
222
+ */
218
223
  export async function dropIdea(
219
224
  hordeDir: string,
220
225
  owner: string,
221
226
  id: number,
222
227
  reason: string | undefined,
228
+ options: { author?: string | undefined } = {},
223
229
  ): Promise<Idea> {
224
230
  return withHordeLock(hordeDir, owner, () => {
225
231
  const doc = readIdeasDoc(hordeDir);
226
232
  const { index, idea } = requireIdea(doc, id);
227
- if (idea.owner !== owner) {
228
- const holder = idea.owner ? `claimed by ${idea.owner}` : "not claimed";
229
- throw new Error(`horde: idea #${id} is ${holder}; you can only drop ideas you claimed`);
233
+ if (idea.owner && idea.owner !== owner) {
234
+ throw new Error(`horde: idea #${id} is claimed by ${idea.owner}; you can only drop ideas you claimed`);
235
+ }
236
+ if (!idea.owner && options.author !== owner) {
237
+ throw new Error(`horde: idea #${id} is not claimed; only the member who added it or its claimant can drop it`);
230
238
  }
231
239
  const dropped: Idea = { id, mark: " ", text: idea.text };
232
240
  doc.lines[index] = formatIdea(dropped);
@@ -235,7 +235,11 @@ export async function confirmSolved(
235
235
  if (state.proposal.member === member) return { ok: false, reason: "the proposer cannot validate their own proposal" };
236
236
  if (revision && state.proposal.revision !== revision) return { ok: false, reason: "stale answer revision; inspect the current proposal" };
237
237
  if (state.contest) return { ok: false, reason: `the proposal was already contested by ${state.contest.member}` };
238
- if (state.confirm) return { ok: true, reason: "already validated", by: state.confirm.member };
238
+ if (state.confirm) {
239
+ // The first confirmation credits the wind-down; later ones are still verification records.
240
+ appendEntry(hordePaths(hordeDir).board, boardEntry(member, `also validated SOLVED: ${requireText(note, "confirm-solved")}`));
241
+ return { ok: true, reason: "already validated", by: state.confirm.member };
242
+ }
239
243
  const signal: SolvedSignal = {
240
244
  member,
241
245
  text: requireText(note, "confirm-solved"),
package/src/prompt.ts CHANGED
@@ -98,7 +98,8 @@ Collaboration loop:
98
98
  - Attach evidence to anyone's work: horde attach target=F# text=observation relation=supports|contradicts|extends,
99
99
  optionally artifactPath=artifacts/file. Returned E# retains the full evidence. Ownership is unnecessary.
100
100
  - horde message target=F#/A#/I#/R# kind=challenge|review-request|question|blocker|handoff|decision text=question,
101
- to=member-N (default owner), refs=[E#]. Challenges/blockers default to blocking acceptance.
101
+ to=member-N (default owner; a request about your own work names another member), refs=[E#].
102
+ Challenges/blockers default to blocking acceptance.
102
103
  - Read horde inbox. Recipient: respond target=Q# revision=hash text=result; ack target=Q# text=plan first only to signal intent.
103
104
  Respond with the challenged target's current revision, not another finding's hash. For an I# resolved by
104
105
  a finding on that idea, explicitly set responseTarget=F#. Superseded F# requests follow the correction.
@@ -530,8 +531,8 @@ export interface RunReportInput extends RunSummaryInput {
530
531
  deliverable?: string | undefined;
531
532
  /** Shared resources claimed during the run, with the non-owners whose records cite each one. */
532
533
  resources?: ReportResource[] | undefined;
533
- /** Members credited with validating the proposal, and those whose confirmation came after it. */
534
- solvedConfirmations?: { credited: string[]; redundant: string[] } | undefined;
534
+ /** Members credited with validating the proposal, and those who confirmed it after that. */
535
+ solvedConfirmations?: { credited: string[]; additional: string[] } | undefined;
535
536
  /** Idea creations this run made, and the ones dedup folded into an existing idea. */
536
537
  ideaCreations?: { created: number; absorbed: Array<{ id: number; count: number }> } | undefined;
537
538
  /** Set when a member reported the goal answered. */
@@ -603,9 +604,9 @@ function artifactDiagnosis(input: RunReportInput): string[] {
603
604
  `- solved: ${input.solved.member} proposed the goal answered (${how}): ${oneLine(input.solved.text, 200)}`,
604
605
  );
605
606
  if (input.solvedConfirmations) {
606
- const { credited, redundant } = input.solvedConfirmations;
607
- const names = (members: string[]): string => (members.length ? ` (${members.join(", ")})` : "");
608
- lines.push(`- confirm-solved: ${credited.length} credited${names(credited)}, ${redundant.length} redundant${names(redundant)}`);
607
+ const { credited, additional } = input.solvedConfirmations;
608
+ const also = additional.length ? `; also confirmed by ${additional.join(", ")}` : "";
609
+ lines.push(`- confirm-solved: ${credited.length ? `credited ${credited.join(", ")}` : "none credited"}${also}`);
609
610
  }
610
611
  lines.push("- answer: ANSWER.md (full answer text in this run directory)");
611
612
  } else if (input.solvedContested) {
@@ -770,10 +771,13 @@ function findingLine(finding: FindingEntry, input: RunReportInput): string {
770
771
  );
771
772
  }
772
773
 
774
+ /** A finding's title is its first line; a single-paragraph finding would otherwise print its whole body as the headline. */
775
+ const HEADLINE_CHARS = 160;
776
+
773
777
  function findingsSection(input: RunReportInput, supersededBy: Map<number, number>): string[] {
774
778
  const reviewRank = (cluster: FindingCluster): number => Math.max(...cluster.findings.map((f) => ({ full: 3, limited: 2, legacy: 1, disputed: 0, unreviewed: 0, superseded: 0 })[findingReviewState(f, input.scopedReviews, input.reviewMessages).status]));
775
779
  const liveClusters = input.clusters.filter((cluster) => !supersededBy.has(cluster.representative.id)).sort((a, b) => reviewRank(b) - reviewRank(a));
776
- const supersededClusters = input.clusters.filter((cluster) => supersededBy.has(cluster.representative.id));
780
+ const superseded = input.findings.filter((finding) => supersededBy.has(finding.id));
777
781
  const lines = [
778
782
  "",
779
783
  "## Distinct findings (ranked by review status, then legacy/support ranking, then importance, " +
@@ -785,16 +789,15 @@ function findingsSection(input: RunReportInput, supersededBy: Map<number, number
785
789
  const delta =
786
790
  cluster.deltaMembers.length > 0 ? `; deltas: ${cluster.deltaMembers.join(", ")}` : "";
787
791
  lines.push(
788
- `${index + 1}. ${cluster.representative.title} ` +
792
+ `${index + 1}. ${oneLine(cluster.representative.title, HEADLINE_CHARS)} ` +
789
793
  `(members: ${cluster.members.join(", ") || "none"}${delta}; ${cluster.findings.length} entries)`,
790
794
  );
791
795
  for (const finding of cluster.findings) lines.push(findingLine(finding, input));
792
796
  });
793
- if (supersededClusters.length > 0) {
797
+ if (superseded.length > 0) {
794
798
  lines.push("", "## Superseded by later findings", "");
795
- for (const cluster of supersededClusters) {
796
- const by = supersededBy.get(cluster.representative.id);
797
- lines.push(`- F${cluster.representative.id} (superseded by F${by}): ${cluster.representative.title}`);
799
+ for (const finding of superseded) {
800
+ lines.push(`- F${finding.id} (superseded by F${supersededBy.get(finding.id)}): ${oneLine(finding.title, HEADLINE_CHARS)}`);
798
801
  }
799
802
  }
800
803
  return lines;
package/src/run/repair.ts CHANGED
@@ -26,18 +26,34 @@ function queueFor(launch: RunLaunch, member: MemberState): PeerMessage[] {
26
26
  .sort((a, b) => repairPriority(a) - repairPriority(b) || a.createdAt.localeCompare(b.createdAt));
27
27
  }
28
28
 
29
- /** Reserve shares for the other queued members and up to two correction/recheck turns. */
30
- export function repairShare(remaining: number, queued: number, laterRounds: number): number {
31
- return Math.max(0, Math.floor(remaining / (Math.max(1, queued) + Math.min(2, Math.max(0, laterRounds)))));
29
+ /**
30
+ * What a queue costs in floors: one cold session covers the administrative threads in it, and
31
+ * each review request or blocking challenge is an inspect/attach/review cycle of about a floor
32
+ * on its own. A seven-handoff queue and a one-review queue both demand one unit; a queue holding
33
+ * three reviews demands three, so its share is sized to the work rather than to the member.
34
+ */
35
+ export function repairDemand(queue: PeerMessage[]): number {
36
+ const checks = queue.filter((m) => m.blocking || (m.kind === "review-request" && /^[AF]\d+$/.test(m.target))).length;
37
+ return Math.max(1, checks);
38
+ }
39
+
40
+ /** The reservation a share is cut from: every pending unit plus up to two correction/recheck turns. */
41
+ function reservedUnits(pendingUnits: number, laterRounds: number): number {
42
+ return Math.max(1, pendingUnits) + Math.min(2, Math.max(0, laterRounds));
43
+ }
44
+
45
+ /** This attempt's share of what remains, in proportion to its demand against every pending unit. */
46
+ export function repairShare(remaining: number, pendingUnits: number, laterRounds: number, units = 1): number {
47
+ return Math.max(0, Math.floor((remaining * units) / reservedUnits(pendingUnits, laterRounds)));
32
48
  }
33
49
 
34
50
  /**
35
51
  * The clock is split only among the attempts the token budget can still pay for. Reserving time
36
52
  * for members the token floor will skip anyway shrank every attempt to a share nobody could use.
37
53
  */
38
- export function repairTimeShare(remainingMs: number, queued: number, laterRounds: number, fundable: number): number {
39
- const reserved = Math.max(1, queued) + Math.min(2, Math.max(0, laterRounds));
40
- return Math.max(0, Math.floor(remainingMs / Math.min(reserved, Math.max(1, fundable))));
54
+ export function repairTimeShare(remainingMs: number, pendingUnits: number, laterRounds: number, fundable: number, units = 1): number {
55
+ const divisor = Math.min(reservedUnits(pendingUnits, laterRounds), Math.max(1, fundable));
56
+ return Math.max(0, Math.min(remainingMs, Math.floor((remainingMs * units) / divisor)));
41
57
  }
42
58
 
43
59
  /**
@@ -64,8 +80,8 @@ export function repairFloors(): RepairFloors {
64
80
  };
65
81
  }
66
82
 
67
- /** Queued members a default budget funds at the floor; the overrides name flat totals instead. */
68
- const FUNDED_ATTEMPTS = 4;
83
+ /** Demand units a default budget funds at the floor; the overrides name flat totals instead. */
84
+ const FUNDED_UNITS = 6;
69
85
 
70
86
  export interface RepairBudget {
71
87
  tokens: number;
@@ -76,18 +92,20 @@ export interface RepairBudget {
76
92
  }
77
93
 
78
94
  /**
79
- * Totals follow the queue: one floor per queued member up to `FUNDED_ATTEMPTS`, plus the two
80
- * correction/recheck shares that `repairShare` reserves for later rounds, unless
81
- * `PI_HORDE_REPAIR_TOKENS` or `PI_HORDE_REPAIR_MS` names a flat total. A flat 50k tokens funded
82
- * two attempts in an eight-member run and skipped six members with queued work.
95
+ * Totals follow the queues: one floor per demand unit across the queued members up to
96
+ * `FUNDED_UNITS`, plus the two correction/recheck shares that `repairShare` reserves for later
97
+ * rounds, unless `PI_HORDE_REPAIR_TOKENS` or `PI_HORDE_REPAIR_MS` names a flat total. A flat 50k
98
+ * tokens funded two attempts in an eight-member run and skipped six members with queued work;
99
+ * one floor per member gave a one-review verifier and a seven-thread owner the same 60 seconds.
83
100
  */
84
- export function repairBudget(queued: number, floors: RepairFloors, rounds: number): RepairBudget {
85
- const funded = Math.min(Math.max(1, queued), FUNDED_ATTEMPTS) + Math.min(2, Math.max(0, rounds - 1));
101
+ export function repairBudget(demands: number[], floors: RepairFloors, rounds: number): RepairBudget {
102
+ const units = demands.reduce((sum, demand) => sum + demand, 0);
103
+ const funded = Math.min(Math.max(1, units), FUNDED_UNITS) + Math.min(2, Math.max(0, rounds - 1));
86
104
  return {
87
105
  tokens: Math.max(0, envNumber("PI_HORDE_REPAIR_TOKENS", floors.tokens * funded)),
88
106
  ms: Math.max(0, envNumber("PI_HORDE_REPAIR_MS", floors.ms * funded)),
89
107
  turns: Math.max(1, envNumber("PI_HORDE_REPAIR_TURNS", 40)),
90
- queued,
108
+ queued: demands.length,
91
109
  };
92
110
  }
93
111
 
@@ -155,7 +173,7 @@ async function repairMember(launch: RunLaunch, member: MemberState, deadline: nu
155
173
  const timeout = setTimeout(() => halt("time"), Math.max(1, deadline - Date.now()));
156
174
  const watchdog = setInterval(check, 100);
157
175
  try {
158
- await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), prior, allowance, floors } });
176
+ await recordEvent({ ...ctx, attempt: member.attempt }, "repair-start", { payload: { messages: queue.map((m) => m.id), demand: repairDemand(queue), prior, allowance, floors } });
159
177
  if (stopReason(launch) || Date.now() >= deadline) {
160
178
  halt(stopReason(launch) ?? "time");
161
179
  member.status = "stopped";
@@ -209,37 +227,44 @@ async function runRepairRounds(launch: RunLaunch, begin: (budget: RepairBudget)
209
227
  if (rounds === 0 || stopReason(launch)) return;
210
228
  const floors = repairFloors();
211
229
  await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
212
- const budget = repairBudget(eligibleMembers(launch).length, floors, rounds);
230
+ const budget = repairBudget(eligibleMembers(launch).map((entry) => repairDemand(entry.queue)), floors, rounds);
213
231
  if (budget.queued === 0) return;
214
232
  const deadline = Date.now() + budget.ms;
215
233
  const exhausted = () => stopReason(launch) || Date.now() >= deadline || (run.repairTokens ?? 0) >= budget.tokens;
216
234
  for (let round = 0; round < rounds; round++) {
217
235
  if (exhausted()) return;
218
236
  if (round > 0) await scheduleCollaboration(launch, { settleMs: 0, liveOnly: false });
219
- const eligible = eligibleMembers(launch);
220
- if (!eligible.length) return;
221
- // Sequential attempts preserve owner -> reviewer ordering while reserving others' shares.
222
- for (let index = 0; index < eligible.length; index++) {
237
+ // One attempt per member per round, picked in priority order re-read before each attempt:
238
+ // a thread that became blocking during an attempt moves its recipient ahead of the
239
+ // administrative queues that were ordered first at round start.
240
+ const served = new Set<string>();
241
+ for (;;) {
223
242
  if (exhausted()) return;
224
- const pending = eligible.slice(index).filter(({ member }) => queueFor(launch, member).length);
243
+ const pending = eligibleMembers(launch).filter((entry) => !served.has(entry.member.name));
244
+ if (!pending.length) break;
245
+ const next = pending[0]!;
246
+ const pendingUnits = pending.reduce((sum, entry) => sum + repairDemand(entry.queue), 0);
247
+ const units = repairDemand(next.queue);
248
+ const unserved = pending.map((entry) => entry.member);
225
249
  const remaining = budget.tokens - (run.repairTokens ?? 0);
226
250
  const laterRounds = rounds - round - 1;
227
- const tokens = repairAllowance(repairShare(remaining, pending.length, laterRounds), remaining, floors.tokens);
251
+ const tokens = repairAllowance(repairShare(remaining, pendingUnits, laterRounds, units), remaining, floors.tokens);
228
252
  if (tokens === undefined) {
229
253
  await begin(budget);
230
- await recordSkipped(launch, pending.map((entry) => entry.member), "insufficient-budget", { remaining, floor: floors.tokens });
254
+ await recordSkipped(launch, unserved, "insufficient-budget", { remaining, floor: floors.tokens });
231
255
  return;
232
256
  }
233
257
  const remainingMs = Math.max(0, deadline - Date.now());
234
258
  const fundable = Math.floor(remaining / Math.max(1, floors.tokens));
235
- const ms = repairAllowance(repairTimeShare(remainingMs, pending.length, laterRounds, fundable), remainingMs, floors.ms);
259
+ const ms = repairAllowance(repairTimeShare(remainingMs, pendingUnits, laterRounds, fundable, units), remainingMs, floors.ms);
236
260
  if (ms === undefined || ms < 1) {
237
261
  await begin(budget);
238
- await recordSkipped(launch, pending.map((entry) => entry.member), "insufficient-time", { remainingMs, floorMs: floors.ms });
262
+ await recordSkipped(launch, unserved, "insufficient-time", { remainingMs, floorMs: floors.ms });
239
263
  return;
240
264
  }
241
265
  await begin(budget);
242
- await repairMember(launch, eligible[index]!.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
266
+ served.add(next.member.name);
267
+ await repairMember(launch, next.member, Date.now() + ms, { tokens, turns: budget.turns }, floors);
243
268
  }
244
269
  }
245
270
  }
package/src/run-graph.ts CHANGED
@@ -284,7 +284,8 @@ function solvedHandler(label: string, tone: EdgeTone): Handler {
284
284
  draft.lastSolved = marker;
285
285
  return;
286
286
  }
287
- if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: event.outcome === "noop" ? `${label} (redundant)` : label, tone });
287
+ const additional = result.confirmation === "additional";
288
+ if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: additional ? `${label} (additional)` : label, tone });
288
289
  };
289
290
  }
290
291
 
@@ -494,7 +495,7 @@ function addRefused(draft: Draft, event: HordeEvent): void {
494
495
  addEdge(draft, marker, refusedTarget(event.action, input), event.action, "negative");
495
496
  }
496
497
 
497
- /** A no-op that still names a result (a duplicate idea, a redundant confirmation) is a repeat, not a refusal. */
498
+ /** A no-op that still names a result (a duplicate idea) is a repeat, not a refusal. */
498
499
  function isRepeat(event: HordeEvent): boolean {
499
500
  return event.outcome === "noop" && record(event.payload.result).noop === true;
500
501
  }
@@ -13,7 +13,7 @@ import { validateFindingResourceCitations } from "../citations.js";
13
13
  import { idleHint } from "./hints.js";
14
14
  import type { HordeToolParams } from "./params.js";
15
15
  import { COLLABORATION_ACTIONS, runCollaborationAction } from "./collaboration-actions.js";
16
- import { recordEvent } from "../ledger/events.js";
16
+ import { readEvents, recordEvent } from "../ledger/events.js";
17
17
  import type { EventContext } from "../ledger/events.js";
18
18
 
19
19
  const CLEAN_STOP_NOTICE =
@@ -30,8 +30,10 @@ const WORK_PLAN_POST =
30
30
  interface ActionResult {
31
31
  text: string;
32
32
  id?: number;
33
- /** True when the call changed nothing: a duplicate idea, or a confirmation after the credited one. */
33
+ /** True when the call changed nothing, such as a duplicate idea. */
34
34
  noop?: boolean;
35
+ /** For confirm-solved: whether this confirmation validated the proposal or joined an earlier one. */
36
+ confirmation?: "credited" | "additional";
35
37
  }
36
38
 
37
39
  interface ActionContext {
@@ -52,6 +54,20 @@ function requireId(params: HordeToolParams): number {
52
54
  return params.id;
53
55
  }
54
56
 
57
+ /** The result line of a closed idea: `text`, or `note` when a member reached for that field instead. */
58
+ function closeNote(params: HordeToolParams): string | undefined {
59
+ return params.text ?? params.note;
60
+ }
61
+
62
+ /** The member whose `idea` write created #id in this run, from the event log; undefined outside a run. */
63
+ function ideaAuthor(hordeDir: string, runId: string | undefined, id: number): string | undefined {
64
+ if (!runId) return undefined;
65
+ const created = readEvents(hordeDir, runId).find(
66
+ (event) => event.action === "idea" && event.outcome === "applied" && (event.payload.result as { id?: unknown } | undefined)?.id === id,
67
+ );
68
+ return created?.actor;
69
+ }
70
+
55
71
  function requireRun(ctx: ActionContext): string {
56
72
  if (!ctx.runId) throw new Error(`horde: ${ctx.params.action} needs an active run`);
57
73
  return ctx.runId;
@@ -283,6 +299,7 @@ async function claimAction({ hordeDir, member, params }: ActionContext): Promise
283
299
 
284
300
  async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
285
301
  const { hordeDir, member, params, runId } = ctx;
302
+ if ((params.text ?? "").trim() === "") throw new Error("horde: resource needs its name in text (note holds the reuse pointer)");
286
303
  const { resource, reused, ownedByCaller } = await claimResource(hordeDir, member, {
287
304
  name: params.text,
288
305
  note: params.note,
@@ -341,9 +358,15 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
341
358
  async function confirmSolvedAction(ctx: ActionContext): Promise<ActionResult> {
342
359
  const answer = await confirmSolved(ctx.hordeDir, requireRun(ctx), ctx.member, ctx.params.text, ctx.params.revision);
343
360
  if (!answer.ok) throw new Error(`horde: ${answer.reason}`);
344
- if (answer.reason !== "validated") return { text: `The SOLVED proposal was already validated by ${answer.by}.`, noop: true };
361
+ if (answer.reason !== "validated") {
362
+ return {
363
+ text: `Recorded your confirmation; the proposal was already validated by ${answer.by}. Finish your claimed work.`,
364
+ confirmation: "additional",
365
+ };
366
+ }
345
367
  return {
346
368
  text: "Validated the SOLVED proposal. Finish your claimed work; the parent will wind down once any coverage requirement is satisfied.",
369
+ confirmation: "credited",
347
370
  };
348
371
  }
349
372
 
@@ -357,19 +380,20 @@ async function contestSolvedAction(ctx: ActionContext): Promise<ActionResult> {
357
380
 
358
381
  async function doneAction(ctx: ActionContext): Promise<ActionResult> {
359
382
  const { hordeDir, member, params } = ctx;
360
- const idea = await finishIdea(hordeDir, member, requireId(params), params.text);
383
+ const idea = await finishIdea(hordeDir, member, requireId(params), closeNote(params));
361
384
  return closedIdea(ctx, idea, `Done #${idea.id}: ${idea.text}`);
362
385
  }
363
386
 
364
387
  async function dropAction(ctx: ActionContext): Promise<ActionResult> {
365
- const { hordeDir, member, params } = ctx;
366
- const idea = await dropIdea(hordeDir, member, requireId(params), params.text);
388
+ const { hordeDir, member, params, runId } = ctx;
389
+ const id = requireId(params);
390
+ const idea = await dropIdea(hordeDir, member, id, closeNote(params), { author: ideaAuthor(hordeDir, runId, id) });
367
391
  return closedIdea(ctx, idea, `Dropped #${idea.id}: ${idea.text}`);
368
392
  }
369
393
 
370
394
  async function ruleOutAction(ctx: ActionContext): Promise<ActionResult> {
371
395
  const { hordeDir, member, params } = ctx;
372
- const idea = await ruleOutIdea(hordeDir, member, requireId(params), params.text);
396
+ const idea = await ruleOutIdea(hordeDir, member, requireId(params), closeNote(params));
373
397
  return closedIdea(ctx, idea, `Ruled out #${idea.id}: ${idea.note ?? "dead end"}`);
374
398
  }
375
399
 
@@ -22,12 +22,31 @@ interface CollaborationCommand {
22
22
  function evidenceRefs(state: CollaborationState, refs: string[] = []): string[] {
23
23
  for (const id of refs) {
24
24
  if (state.evidence.some((e) => e.id === id)) continue;
25
+ // A review is the record members most want to cite; it already names the evidence it rests on.
26
+ const review = state.reviews.find((r) => r.id === id);
27
+ if (review) throw new Error(`horde: ${id} is a review, not evidence; cite its evidence instead: ${review.evidence.join(", ") || "none recorded"}`);
28
+ // Evidence ids share the event counter, so the E# of the event that recorded V#, A# or Q# is
29
+ // the guess a member makes when it knows the record but not its evidence.
30
+ const recorded = recordedAtEvent(state, id);
31
+ if (recorded) throw new Error(`horde: ${id} is the event that recorded ${recorded.id}, not evidence; ${recorded.hint}`);
25
32
  const kind = /^[A-Z]\d+$/.test(id) && !id.startsWith("E") ? `; ${id} is not evidence, refs take E# ids (attach evidence first, or name ${id} as the target)` : "";
26
33
  throw new Error(`horde: unknown evidence ${id}${kind}`);
27
34
  }
28
35
  return refs;
29
36
  }
30
37
 
38
+ function recordedAtEvent(state: CollaborationState, id: string): { id: string; hint: string } | undefined {
39
+ const seq = /^E(\d+)$/.exec(id)?.[1];
40
+ if (!seq) return undefined;
41
+ const review = state.reviews.find((r) => r.id === `V${seq}`);
42
+ if (review) return { id: review.id, hint: `cite ${review.id}'s evidence instead: ${review.evidence.join(", ") || "none recorded"}` };
43
+ const artifact = state.artifacts.find((a) => a.id === `A${seq}`);
44
+ if (artifact) return { id: artifact.id, hint: `attach evidence to ${artifact.id} first, or name ${artifact.id} as the target` };
45
+ const message = state.messages.find((m) => m.id === `Q${seq}`);
46
+ if (message) return { id: message.id, hint: `cite the evidence on ${message.id} instead: ${[...message.evidence, ...(message.responseEvidence ?? [])].join(", ") || "none recorded"}` };
47
+ return undefined;
48
+ }
49
+
31
50
  /** Name the stale ref and both revisions, so a retry does not have to guess which evidence to replace. */
32
51
  function staleEvidence(state: CollaborationState, refs: string[], target: string, revision: string): string | undefined {
33
52
  const stale = refs.map((id) => state.evidence.find((e) => e.id === id)).find((e) => e && (e.target !== target || e.revision !== revision));
@@ -43,6 +62,8 @@ function newMessage(c: CollaborationCommand): EventDraft {
43
62
  const task = path.join(ctx.hordeDir, "runs", ctx.runId, "TASK.md");
44
63
  const count = fs.existsSync(task) ? Number(/^Members: (\d+)/m.exec(fs.readFileSync(task, "utf8"))?.[1]) : 0;
45
64
  if (!/^member-[1-9]\d*$/.test(to) || (count && Number(to.slice(7)) > count)) throw new Error("horde: recipient must be a member of this run");
65
+ // The recipient defaults to the target's owner, which is the sender itself on its own work.
66
+ if (to === ctx.actor) throw new Error(`horde: a message cannot be addressed to its sender; ${target} is yours, name another member with to=member-N`);
46
67
  if (p.revision && p.revision !== info.revision) throw new Error("horde: target changed; inspect current revision");
47
68
  const message: PeerMessage = {
48
69
  id: `Q${seq}`, sender: ctx.actor, to, target, revision: info.revision, kind: p.kind ?? "question",
@@ -175,7 +196,29 @@ function recordReview(c: CollaborationCommand): EventDraft {
175
196
  verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
176
197
  text, evidence: refs },
177
198
  ...completeAssignedReview(c, target, `V${seq}`, text, refs),
178
- ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
199
+ ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}),
200
+ ...(p.verdict === "supports" && !p.unchecked.trim() ? coverageClearance(c, target, info, `V${seq}`, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
201
+ }
202
+
203
+ /** The challenge a partially-supports review raised in its own event, as opposed to an objection a member wrote. */
204
+ function coverageChallenge(state: CollaborationState, message: PeerMessage): boolean {
205
+ return state.reviews.some((r) => r.id === `V${message.id.slice(1)}` && r.actor === message.sender && r.verdict === "partially-supports");
206
+ }
207
+
208
+ /**
209
+ * A partial review opens a blocking challenge that only its sender may resolve. When that review
210
+ * contradicted nothing and another member later supports the whole revision, the unchecked scope
211
+ * is covered: the full review clears the challenge, and the resolution names both reviews.
212
+ */
213
+ function coverageClearance(c: CollaborationCommand, target: string, info: { revision: string }, reviewId: string, refs: string[]): Record<string, unknown> {
214
+ const cleared = c.state.messages.filter((m) =>
215
+ m.target === target && m.revision === info.revision && m.blocking && m.state !== "resolved" && m.sender !== c.ctx.actor && coverageChallenge(c.state, m));
216
+ if (!cleared.length) return {};
217
+ return { clearances: cleared.map((m) => ({
218
+ id: m.id,
219
+ text: `cleared by ${reviewId}: ${c.ctx.actor}'s full-scope supporting review of ${target}@${info.revision.slice(0, 12)} covers the scope V${m.id.slice(1)} left unchecked`,
220
+ refs,
221
+ })) };
179
222
  }
180
223
 
181
224
  /** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
@@ -59,7 +59,12 @@ export function retainArtifact(ctx: EventContext, file: string): { path: string;
59
59
  const runDir = path.join(ctx.hordeDir, "runs", ctx.runId);
60
60
  const root = fs.realpathSync(path.join(runDir, "artifacts"));
61
61
  const candidate = path.isAbsolute(file) ? file : path.join(runDir, file);
62
- const real = fs.realpathSync(candidate);
62
+ let real: string;
63
+ try {
64
+ real = fs.realpathSync(candidate);
65
+ } catch {
66
+ throw new Error(`horde: artifactPath not found: ${file}; artifacts live under runs/${ctx.runId}/artifacts/`);
67
+ }
63
68
  if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile()) throw new Error("horde: evidence must be a file inside this run's artifacts directory");
64
69
  const bytes = fs.readFileSync(real);
65
70
  const hash = hashText(bytes);
@@ -75,7 +75,11 @@ export const HordeParams = Type.Object({
75
75
  }),
76
76
  ),
77
77
  note: Type.Optional(
78
- Type.String({ description: "For a resource: a short pointer (path, command, or fact id) others should reuse" }),
78
+ Type.String({
79
+ description:
80
+ "For a resource: a short pointer (path, command, or fact id) others should reuse. For done/drop/rule-out: " +
81
+ "the result line, when text is not given",
82
+ }),
79
83
  ),
80
84
  cleanup: Type.Optional(
81
85
  Type.String({