agent-horde 0.1.1 → 0.1.3

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.1
91
+ pi install git:github.com/eliyastein/agent-horde@v0.1.3
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
@@ -109,14 +109,21 @@ Start pi in the directory the horde should work on, then:
109
109
 
110
110
  Six members start on the prompt with the session's model and thinking level. The command returns
111
111
  at once; a widget above the editor shows each member's status, current tool, turns, and resets,
112
- and the dashboard is at `http://127.0.0.1:7433/`. When the work is partitionable, name the parts:
112
+ and the dashboard is at `http://127.0.0.1:7433/`. In this form members divide the work
113
+ themselves: each reads the board, claims an idea nobody else owns, and moves on when it is done or
114
+ ruled out.
115
+
116
+ When you already know how the work splits, use `plan` to fix the partition at launch:
113
117
 
114
118
  ```
115
119
  /agent-horde plan --dashboard 4 parser; type checker; code generator
116
120
  ```
117
121
 
118
- Each member takes one part. Members without a part become verifiers, and an unpartitioned run of
119
- three or more members makes its last member the verifier.
122
+ Each layer becomes a seeded idea owned by one member, recorded in the run's `PLAN.md`, and that
123
+ member works only its layer. Layers are assigned round-robin, so more layers than members gives
124
+ some members two. Members without a layer become verifiers, and an unpartitioned run of three or
125
+ more members makes its last member the verifier. `plan` suits "analyse each X" tasks; split design
126
+ work by output type, such as spec, prototype, and generator, rather than by theme.
120
127
 
121
128
  | command | effect |
122
129
  |---|---|
package/docs/REFERENCE.md CHANGED
@@ -37,7 +37,9 @@ 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
- A user stop skips this phase.
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.
41
43
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
42
44
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
43
45
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -83,7 +85,7 @@ Every page has a JSON twin under `/api/<same path>`.
83
85
  | `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
84
86
  | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
85
87
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
86
- | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them |
88
+ | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them; `/resources` also lists who cited each resource in the active run, as REPORT.md counts reuse |
87
89
 
88
90
  Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
89
91
  the session files, so they show end cause, turns, and usage but no activity.
@@ -175,9 +177,13 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
175
177
  `reproduced` review from a non-owner. A `document` artifact (.md, .txt, .rst, .adoc, .pdf)
176
178
  accepts a full-scope `sourced` or `derived` review against its criteria. Changing criteria or
177
179
  dependencies needs changed bytes and a fresh review. Open blockers and challenges block
178
- acceptance; `defer` keeps the debt visible.
180
+ acceptance; `defer` keeps the debt visible, the recipient can still `respond` to a deferred
181
+ request, and the requester then resolves it. A challenger can withdraw its own blocking
182
+ challenge on an F# or A# before any response by recording a supporting review of the current
183
+ revision and resolving with evidence on it; limits that review discloses remain acceptance debt.
179
184
  - **Inbox.** Targeted messages are inserted at turn boundaries, for at most
180
- `PI_HORDE_INBOX_TURNS` extra turns per session. Delivery is a context insertion, not
185
+ `PI_HORDE_INBOX_TURNS` extra turns per session. A recipient answers with `respond`; `ack` is an
186
+ optional first step that signals intent. Delivery is a context insertion, not
181
187
  comprehension. Message and review bodies are untrusted input, not tool instructions.
182
188
  - **Reminders.** Every `PI_HORDE_REMIND_CALLS` tool calls or `PI_HORDE_REMIND_MS`, the tool
183
189
  result restates the task, role, and claimed ideas. A member that finishes its last idea while
@@ -234,9 +240,11 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
234
240
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
235
241
  | `PI_HORDE_ALLOW_UNVERIFIED` | unset | `1` accepts a validated answer with unverified findings |
236
242
  | `PI_HORDE_REPAIR_ROUNDS` | 4 | post-discovery repair rounds (max 6; `0` disables) |
237
- | `PI_HORDE_REPAIR_MS` | 120000 | total repair time |
238
- | `PI_HORDE_REPAIR_TOKENS` | 50000 | total repair tokens |
239
- | `PI_HORDE_REPAIR_MIN_TOKENS` | 20000 | floor per attempt; below it scheduling stops with a `repair-skipped` event |
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 |
245
+ | `PI_HORDE_REPAIR_MIN_TOKENS` | 40000 | token floor per attempt; below it scheduling stops with a `repair-skipped` event |
246
+ | `PI_HORDE_REPAIR_MIN_MS` | 60000 | time floor per attempt; the clock is split only among attempts the token budget can fund |
247
+ | `PI_HORDE_REPAIR_TURNS` | 40 | turn cap per attempt; an attempt still reading its inbox at the cap is stopped with `turns` |
240
248
  | `PI_HORDE_DELIVERABLE` | on | `off` skips the synthesis member |
241
249
  | `PI_HORDE_POSTMORTEM` | on | `off` skips the automatic post-mortem |
242
250
  | `PI_HORDE_POSTMORTEM_MS` | 300000 | post-mortem reviewer timeout |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { clusterFindings } from "./ledger/clusters.js";
4
4
  import { collaborationState } from "./ledger/collaboration.js";
5
- import { findingReviewState } from "./ledger/findings.js";
5
+ import type { CollaborationState, ReviewRecord } from "./ledger/collaboration.js";
6
+ import { findingReviewState, findingRevision } from "./ledger/findings.js";
6
7
  import type { FindingEntry } from "./ledger/findings.js";
7
8
  import { listResources, readRunResources } from "./ledger/resources.js";
8
9
  import type { ResourceEntry } from "./ledger/resources.js";
@@ -215,6 +216,57 @@ export function ledgerCitationWarnings(
215
216
  return [...new Set(warnings)];
216
217
  }
217
218
 
219
+ /** The acceptance vocabulary for a finding nobody reviewed at its current revision. */
220
+ const UNREVIEWED_LABEL = /unverified|unreviewed|no (?:independent|current-revision) review|not (?:independently )?reviewed/i;
221
+
222
+ /**
223
+ * True when the text cites F<id> and no citing paragraph carries the unreviewed label. One label
224
+ * at the top of a document cannot cover every later citation; a label next to any citation can.
225
+ */
226
+ function citedWithoutLabel(text: string, id: number): boolean {
227
+ const cite = new RegExp(`\\bF${id}\\b`);
228
+ const citing = text.split(/\n\s*\n/).filter((paragraph) => cite.test(paragraph));
229
+ return citing.length > 0 && !citing.some((paragraph) => UNREVIEWED_LABEL.test(paragraph));
230
+ }
231
+
232
+ /**
233
+ * The units a review citation is judged in: paragraphs, except that each list item and table row
234
+ * 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.
236
+ */
237
+ function citationUnits(text: string): string[] {
238
+ return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : [paragraph]));
239
+ }
240
+
241
+ /**
242
+ * A V# cited next to an F# must review that finding at its current revision. A review of the
243
+ * finding it superseded, of an artifact that merely depends on it, or of an earlier revision is
244
+ * not support for the claim it sits next to. A V# with no review record is left alone: "V8" in
245
+ * prose is an engine, not a citation.
246
+ */
247
+ function reviewCitationWarnings(label: string, text: string, findings: FindingEntry[], reviews: ReviewRecord[]): string[] {
248
+ const warnings: string[] = [];
249
+ for (const paragraph of citationUnits(text)) {
250
+ const cited = [...new Set([...paragraph.matchAll(/\bF(\d+)\b/g)].map((match) => `F${match[1]}`))];
251
+ if (cited.length === 0) continue;
252
+ const targets = new Set([...cited, ...[...paragraph.matchAll(/\bA(\d+)\b/g)].map((match) => `A${match[1]}`)]);
253
+ for (const match of paragraph.matchAll(/\bV(\d+)\b/g)) {
254
+ const id = `V${match[1]}`;
255
+ const review = reviews.find((entry) => entry.id === id);
256
+ if (!review) continue;
257
+ if (!targets.has(review.target)) {
258
+ warnings.push(`${label} cites ${id} next to ${cited.join(", ")}, but ${id} reviews ${review.target}`);
259
+ continue;
260
+ }
261
+ const finding = findings.find((entry) => `F${entry.id}` === review.target);
262
+ if (finding && review.revision !== findingRevision(finding)) {
263
+ warnings.push(`${label} cites ${id} for ${review.target}, but it reviewed an earlier revision; the current revision has no such review`);
264
+ }
265
+ }
266
+ }
267
+ return warnings;
268
+ }
269
+
218
270
  export function generatedArtifactWarnings(
219
271
  run: HordeRun,
220
272
  label: string,
@@ -238,14 +290,41 @@ export function generatedArtifactWarnings(
238
290
  const cited = checks.filter((f) => f.check.status === status && new RegExp(`\\bF${f.id}\\b`).test(text));
239
291
  if (cited.length) warnings.push(`${label} cites ${status === "limited" ? "scope-limited reviews" : "disputed findings"} (${cited.map((f) => `F${f.id}`).join(", ")}); consult the exact checked/unchecked scope and unresolved challenges in COLLABORATION.md`);
240
292
  }
241
- const citedUnverified = unverified.filter((finding) =>
242
- new RegExp(`\\bF${finding.id}\\b`).test(text),
243
- );
244
- if (citedUnverified.length > 0 && !/unverified/i.test(text)) {
293
+ const unlabeled = unverified.filter((finding) => citedWithoutLabel(text, finding.id));
294
+ if (unlabeled.length > 0) {
245
295
  warnings.push(
246
- `${label} cites unverified findings (${citedUnverified.map((finding) => `F${finding.id}`).join(", ")}) ` +
247
- "without labeling them unverified",
296
+ `${label} cites findings with no independent review (${unlabeled.map((finding) => `F${finding.id}`).join(", ")}) ` +
297
+ "without saying so in the citing paragraph",
248
298
  );
249
299
  }
300
+ warnings.push(...reviewCitationWarnings(label, text, findings, state.reviews));
250
301
  return [...new Set(warnings)];
251
302
  }
303
+
304
+ /**
305
+ * Non-owner members whose findings, evidence, reviews or leases name a resource. A member that
306
+ * works in a shared lab rarely re-runs `horde resource`, so explicit reclaims alone undercount reuse.
307
+ */
308
+ export function resourceCitingMembers(
309
+ resources: ResourceEntry[],
310
+ findings: FindingEntry[],
311
+ state: Pick<CollaborationState, "evidence" | "reviews" | "resources">,
312
+ ): Map<number, string[]> {
313
+ const texts: Array<{ member: string; text: string }> = [
314
+ ...findings.map((finding) => ({ member: finding.author, text: finding.body })),
315
+ ...state.evidence.map((record) => ({ member: record.actor, text: record.text })),
316
+ ...state.reviews.map((review) => ({ member: review.actor, text: `${review.checked} ${review.text}` })),
317
+ ];
318
+ const out = new Map<number, string[]>();
319
+ for (const resource of resources) {
320
+ const members = new Set<string>();
321
+ for (const { member, text } of texts) {
322
+ if (member !== resource.owner && resourceCitations(text).includes(resource.id)) members.add(member);
323
+ }
324
+ for (const contract of state.resources) {
325
+ if (contract.target === `R${resource.id}` && contract.owner !== resource.owner) members.add(contract.owner);
326
+ }
327
+ out.set(resource.id, [...members].sort());
328
+ }
329
+ return out;
330
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { artifactAccepted, blockingMessages, collaborationState, openMessages } from "./ledger/collaboration.js";
4
- import type { ArtifactRecord, CollaborationState } from "./ledger/collaboration.js";
4
+ import type { ArtifactRecord, CollaborationState, PeerMessage } from "./ledger/collaboration.js";
5
5
  import { readEvents } from "./ledger/events.js";
6
6
  import { findingReviewState, findingRevision, listFindingsForRun } from "./ledger/findings.js";
7
7
  import { listIdeas } from "./ledger/ideas.js";
@@ -84,19 +84,35 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
84
84
  unresolved: openMessages(state).length, blocking: blockingMessages(state).length,
85
85
  resolvedChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "resolved").length,
86
86
  deferredChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "deferred").length,
87
+ openChallenges: messages.filter((m) => m.kind === "challenge" && m.state !== "resolved" && m.state !== "deferred").length,
87
88
  meanDeliveryMs: delays.length ? Math.round(delays.reduce((a, b) => a + b, 0) / delays.length) : 0,
88
89
  acceptedArtifacts: acceptedArtifacts.length,
89
90
  acceptedDocuments: acceptedDocuments.length, acceptedRunnableArtifacts: acceptedRunnableArtifacts.length,
90
91
  } };
91
92
  }
92
93
 
94
+ function withEvidence(text: string, evidence: string[] | undefined): string {
95
+ return evidence?.length ? `${text} (evidence ${evidence.join(", ")})` : text;
96
+ }
97
+
98
+ /** The response and the resolution are different actors' decisions; each shows with its own evidence. */
99
+ function messageMarkdown(m: PeerMessage): string {
100
+ const lines = [
101
+ `- ${m.id} ${m.sender} -> ${m.to} ${m.target}@${m.revision} [${m.state}] ${m.kind}${m.blocking ? " BLOCKING" : ""}`,
102
+ ` ${m.text}`,
103
+ ` Response: ${m.response ? withEvidence(m.response, m.responseEvidence) : "pending"}`,
104
+ ];
105
+ if (m.resolution !== undefined) lines.push(` ${m.state === "deferred" ? "Deferred" : "Resolution"}: ${withEvidence(m.resolution, m.resolutionEvidence)}`);
106
+ lines.push(` Evidence: ${m.evidence.join(", ") || "none"}; created ${m.createdAt}; updated ${m.updatedAt}`);
107
+ return lines.join("\n");
108
+ }
109
+
93
110
  function collaborationMarkdown(report: CollaborationReport): string {
94
111
  const { state } = report;
95
112
  return [
96
113
  `# Collaboration ${report.runId}`, `\nEvent high-water mark: ${report.sequence}. Delivery records context insertion, not comprehension.`,
97
114
  "\n## Acceptance issues", ...(report.issues.length ? report.issues.map((i) => `- ${i}`) : ["- No protocol-level issues recorded; task acceptance still requires coverage and review."]),
98
- "\n## Questions, challenges and handoffs", ...state.messages.map((m) =>
99
- `- ${m.id} ${m.sender} -> ${m.to} ${m.target}@${m.revision} [${m.state}] ${m.kind}${m.blocking ? " BLOCKING" : ""}\n ${m.text}\n Response: ${m.response ?? "pending"}\n Evidence: ${m.evidence.join(", ") || "none"}; created ${m.createdAt}; updated ${m.updatedAt}`),
115
+ "\n## Questions, challenges and handoffs", ...state.messages.map(messageMarkdown),
100
116
  "\n## Artifact revisions", ...state.artifacts.map((a) => `- ${a.id} ${a.path}@${a.hash} owner=${a.owner} kind=${a.kind ?? "runnable"} snapshot=${a.snapshot} accepted=${report.acceptedArtifacts.includes(a.id)}\n Criteria: ${a.criteria}\n Dependencies: ${JSON.stringify(a.dependencies)}`),
101
117
  "\n## Reviews", ...state.reviews.map((r) => `- ${r.id} ${r.target}@${r.revision} @${r.actor} ${r.verdict} [${r.support}] evidence=${r.evidence.join(",")}\n Checked: ${r.checked}\n Unchecked: ${r.unchecked || "none declared"}\n ${r.text}`),
102
118
  "\n## Evidence", ...state.evidence.map((e) => `- ${e.id} ${e.target}@${e.revision} @${e.actor} ${e.relation}${e.path ? ` ${e.path}@${e.hash}` : ""}\n ${e.text}`),
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { MAX_MEMBERS } from "./args.js";
4
+ import { resourceCitingMembers } from "./citations.js";
4
5
  import { loadCollaborationReport } from "./collaboration-report.js";
5
6
  import type { CollaborationReport } from "./collaboration-report.js";
6
7
  import { readEvents } from "./ledger/events.js";
@@ -14,7 +15,7 @@ import { listFacts } from "./ledger/facts.js";
14
15
  import type { FactEntry } from "./ledger/facts.js";
15
16
  import { hordePaths } from "./ledger/files.js";
16
17
  import { collaborationState } from "./ledger/collaboration.js";
17
- import { findingReviewState, parseFindings } from "./ledger/findings.js";
18
+ import { findingReviewState, listFindingsForRun, parseFindings } from "./ledger/findings.js";
18
19
  import type { FindingEntry } from "./ledger/findings.js";
19
20
  import { listIdeas } from "./ledger/ideas.js";
20
21
  import type { Idea } from "./ledger/ideas.js";
@@ -802,13 +803,24 @@ export function loadClaims(src: DashboardSource): ClaimsVm {
802
803
  }
803
804
 
804
805
  export interface ResourcesVm {
805
- resources: ResourceEntry[];
806
+ /** The active run whose findings, evidence, reviews and leases were scanned for citations. */
807
+ run?: string | undefined;
808
+ resources: Array<ResourceEntry & { citedBy: string[] }>;
806
809
  }
807
810
 
808
- /** Shared labs/harnesses/control corpora and who reused them. */
811
+ /**
812
+ * Shared labs/harnesses/control corpora, who reclaimed them, and who cited them. Citations are
813
+ * counted the way REPORT.md counts them, from the active run's ledgers; without an active run
814
+ * only explicit reclaims are known.
815
+ */
809
816
  export function loadResources(src: DashboardSource): ResourcesVm {
810
817
  const paths = hordePaths(src.hordeDir);
811
- return { resources: fs.existsSync(paths.resources) ? listResources(src.hordeDir) : [] };
818
+ const resources = fs.existsSync(paths.resources) ? listResources(src.hordeDir) : [];
819
+ const live = src.getActive();
820
+ const citing = live
821
+ ? resourceCitingMembers(resources, listFindingsForRun(src.hordeDir, live.id), collaborationState(src.hordeDir, live.id))
822
+ : new Map<number, string[]>();
823
+ return { run: live?.id, resources: resources.map((resource) => ({ ...resource, citedBy: citing.get(resource.id) ?? [] })) };
812
824
  }
813
825
 
814
826
  export interface FactsVm {
@@ -3,6 +3,7 @@ import type { ClaimEntry } from "./ledger/claims.js";
3
3
  import type { FactEntry } from "./ledger/facts.js";
4
4
  import type { FindingEntry } from "./ledger/findings.js";
5
5
  import type { Idea } from "./ledger/ideas.js";
6
+ import type { PeerMessage } from "./ledger/collaboration.js";
6
7
  import type {
7
8
  BoardVm,
8
9
  CollaborationVm,
@@ -122,12 +123,24 @@ function anchor(id: string): string {
122
123
  return `<span id="${e(id)}">${e(id)}</span>`;
123
124
  }
124
125
 
126
+ function withEvidence(text: string, evidence: string[] | undefined): string {
127
+ return evidence?.length ? `${text} (evidence ${evidence.join(", ")})` : text;
128
+ }
129
+
130
+ /** The request, the recipient's response and the sender's resolution, each with its own evidence. */
131
+ function messageThread(m: PeerMessage): string {
132
+ const lines = [m.text, `Response: ${m.response ? withEvidence(m.response, m.responseEvidence) : "pending"}`];
133
+ if (m.resolution !== undefined) lines.push(`${m.state === "deferred" ? "Deferred" : "Resolution"}: ${withEvidence(m.resolution, m.resolutionEvidence)}`);
134
+ lines.push(`Evidence: ${m.evidence.join(", ") || "none"}`);
135
+ return lines.join("\n");
136
+ }
137
+
125
138
  export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): string {
126
139
  const { report } = vm;
127
140
  const { state } = report;
128
141
  const messages = table(["request", "owner", "state / age", "target", "question / response"], state.messages.map((m) => [
129
142
  anchor(m.id), e(`${m.sender} -> ${m.to}`), e(`${m.state}${m.blocking ? " BLOCKING" : ""} / ${formatElapsed(Date.now() - Date.parse(m.createdAt))}`),
130
- e(`${m.target}@${m.revision}`), pre(`${m.text}\nResponse: ${m.response ?? "pending"}\nEvidence: ${m.evidence.join(", ")}`),
143
+ e(`${m.target}@${m.revision}`), pre(messageThread(m)),
131
144
  ]));
132
145
  const reviews = table(["review", "target revision", "verdict", "scope / evidence"], state.reviews.map((r) => [`${anchor(r.id)} @${e(r.actor)}`, e(`${r.target}@${r.revision}`), e(`${r.verdict} [${r.support}]`), pre(`Checked: ${r.checked}\nUnchecked: ${r.unchecked || "none declared"}\nEvidence: ${r.evidence.join(", ")}\n${r.text}`)]));
133
146
  const artifacts = table(["artifact", "hash / dependencies", "acceptance"], state.artifacts.map((a) => [`${anchor(a.id)} ${e(`${a.path} @${a.owner} (${a.kind ?? "runnable"})`)}`, pre(`${a.hash}\n${JSON.stringify(a.dependencies)}`), e(report.acceptedArtifacts.includes(a.id) ? "accepted" : "excluded / needs review")]));
@@ -615,15 +628,17 @@ export function renderResources(vm: ResourcesVm, ctx: RenderContext): string {
615
628
  e(resource.name),
616
629
  authorLink(resource.owner, ctx.activeId),
617
630
  e(resource.note ?? ""),
618
- resource.reusers.length > 0
619
- ? resource.reusers.map((name) => authorLink(name, ctx.activeId)).join(", ")
620
- : "",
631
+ resource.reusers.map((name) => authorLink(name, ctx.activeId)).join(", "),
632
+ resource.citedBy.map((name) => authorLink(name, ctx.activeId)).join(", "),
621
633
  e(resource.cleanup ?? ""),
622
634
  ]);
635
+ const scope = vm.run
636
+ ? `<p>cited by: non-owners whose findings, evidence, reviews or leases name the resource in run ${e(vm.run)}</p>`
637
+ : "<p>cited by: needs an active run; only explicit reclaims are shown</p>";
623
638
  return page(
624
639
  "resources",
625
640
  [{ text: "resources" }],
626
- table(["id", "name", "owner", "note", "reused by", "cleanup"], rows, "no shared resources claimed"),
641
+ scope + table(["id", "name", "owner", "note", "reused by", "cited by", "cleanup"], rows, "no shared resources claimed"),
627
642
  ctx,
628
643
  );
629
644
  }
@@ -19,7 +19,11 @@ export interface PeerMessage {
19
19
  response?: string;
20
20
  responseRevision?: string;
21
21
  responseTarget?: string;
22
+ /** Evidence the recipient attached to its response. */
23
+ responseEvidence?: string[];
22
24
  resolution?: string;
25
+ /** Evidence the sender attached when resolving or deferring. */
26
+ resolutionEvidence?: string[];
23
27
  deliveries: Record<string, string>;
24
28
  }
25
29
  interface EvidenceRecord {
@@ -104,6 +108,7 @@ function applyTransition(state: CollaborationState, event: HordeEvent): void {
104
108
  const t = event.payload.transition as { id: string; state: MessageState; text?: string; revision?: string; responseTarget?: string; attempt?: string };
105
109
  const message = state.messages.find((m) => m.id === t.id);
106
110
  if (!message) return;
111
+ const refs = Array.isArray(event.payload.refs) ? (event.payload.refs as string[]) : [];
107
112
  if (t.state === "delivered") {
108
113
  message.deliveries[t.attempt ?? event.attempt] = event.at;
109
114
  if (message.state === "pending") message.state = "delivered";
@@ -113,7 +118,11 @@ function applyTransition(state: CollaborationState, event: HordeEvent): void {
113
118
  message.response = t.text;
114
119
  message.responseRevision = t.revision;
115
120
  message.responseTarget = t.responseTarget;
116
- } else if (t.state === "resolved" || t.state === "deferred") message.resolution = t.text;
121
+ message.responseEvidence = refs;
122
+ } else if (t.state === "resolved" || t.state === "deferred") {
123
+ message.resolution = t.text;
124
+ message.resolutionEvidence = refs;
125
+ }
117
126
  }
118
127
  message.updatedAt = event.at;
119
128
  }
@@ -0,0 +1,34 @@
1
+ import type { HordeEvent } from "./events.js";
2
+
3
+ export interface SolvedConfirmations {
4
+ /** Members whose confirm-solved was the one credited for a proposal revision. */
5
+ credited: string[];
6
+ /** Members whose confirm-solved arrived after the credited one and changed nothing. */
7
+ redundant: string[];
8
+ }
9
+
10
+ /** The report shows how many members spent a confirmation on an already validated proposal. */
11
+ export function solvedConfirmations(events: HordeEvent[]): SolvedConfirmations {
12
+ const out: SolvedConfirmations = { credited: [], redundant: [] };
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);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export function ideaCreationCount(events: HordeEvent[]): number {
22
+ return events.filter((event) => event.action === "idea" && event.outcome === "applied").length;
23
+ }
24
+
25
+ /** Idea creations the dedup folded into an existing idea, by that idea's id, most absorbed first. */
26
+ export function absorbedIdeaCreations(events: HordeEvent[]): Array<{ id: number; count: number }> {
27
+ const counts = new Map<number, number>();
28
+ for (const event of events) {
29
+ if (event.action !== "idea" || event.outcome !== "noop") continue;
30
+ const id = (event.payload.result as { id?: unknown } | undefined)?.id;
31
+ if (typeof id === "number") counts.set(id, (counts.get(id) ?? 0) + 1);
32
+ }
33
+ return [...counts].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count || a.id - b.id);
34
+ }
@@ -159,6 +159,27 @@ function cleanupCommandProblem(command: string, runId?: string): string | undefi
159
159
  return undefined;
160
160
  }
161
161
 
162
+ /** The runtime object a cleanup removes, so two labs that share one container name collide. */
163
+ function cleanupObject(command: string): string {
164
+ const normalized = command.trim().replace(/\s+/g, " ");
165
+ const container = /^(?:docker|podman)\s+(?:container\s+|image\s+)?(?:rm|rmi|stop|kill)\b(?:\s+-\S+)*\s+(\S+)/i.exec(normalized);
166
+ if (container) return `object:${container[1]}`;
167
+ const proc = /^pkill\b(?:\s+-\S+)*\s+(.+)$/i.exec(normalized);
168
+ if (proc) return `process:${proc[1]}`;
169
+ return `command:${normalized}`;
170
+ }
171
+
172
+ /** One teardown must not remove two labs: refuse a cleanup another resource of this run registered. */
173
+ function cleanupCollision(resources: ResourceEntry[], touched: Set<number>, command: string, selfId?: number): ResourceEntry | undefined {
174
+ if (!command) return undefined;
175
+ const object = cleanupObject(command);
176
+ return resources.find((r) => r.id !== selfId && touched.has(r.id) && r.cleanup !== undefined && cleanupObject(r.cleanup) === object);
177
+ }
178
+
179
+ function collisionMessage(command: string, other: ResourceEntry): string {
180
+ return `horde: cleanup \`${command}\` is already registered by R${other.id} "${other.name}"; reuse R${other.id} or pick a distinct container name`;
181
+ }
182
+
162
183
  // Filesystem paths cited in a resource note, used to detect overlapping scopes.
163
184
  // A URL, a bare filename, or a docker image ref (no slash, or a scheme) is ignored.
164
185
  function resourceNotePaths(note: string): string[] {
@@ -223,6 +244,11 @@ export async function claimResource(
223
244
  const touch = (resourceId: number): void => {
224
245
  if (runId) recordRunResource(hordeDir, runId, resourceId);
225
246
  };
247
+ const touched = new Set(runId ? readRunResources(path.join(hordeDir, "runs", runId)) : []);
248
+ const collide = (selfId?: number): void => {
249
+ const other = cleanupCollision(resources, touched, cleanupText, selfId);
250
+ if (other) throw new Error(collisionMessage(cleanupText, other));
251
+ };
226
252
  const normalized = resourceName.toLowerCase();
227
253
  let existing = resources.find((resource) => resource.name.toLowerCase() === normalized);
228
254
  if (!existing) existing = bestResourceMatch(resources, resourceName, noteText);
@@ -232,6 +258,7 @@ export async function claimResource(
232
258
  if (existing.owner === owner && existing.name.toLowerCase() === normalized &&
233
259
  (!runId || existing.ownerRunId === runId)) {
234
260
  if ((noteText && noteText !== existing.note) || (cleanupText && cleanupText !== existing.cleanup)) {
261
+ if (cleanupText && cleanupText !== existing.cleanup) collide(existing.id);
235
262
  const updated: ResourceEntry = {
236
263
  ...existing,
237
264
  note: noteText || existing.note,
@@ -248,6 +275,7 @@ export async function claimResource(
248
275
  }
249
276
  // A reuser may supply a missing teardown command, but cannot overwrite the owner's.
250
277
  const adoptCleanup = cleanupText && !existing.cleanup;
278
+ if (adoptCleanup) collide(existing.id);
251
279
  if (!existing.reusers.includes(owner) || adoptCleanup) {
252
280
  const updated: ResourceEntry = {
253
281
  ...existing,
@@ -272,6 +300,7 @@ export async function claimResource(
272
300
  `reuse R${overlap.id} and cite it, or claim a disjoint path`,
273
301
  );
274
302
  }
303
+ collide();
275
304
  const id = resources.reduce((max, resource) => Math.max(max, resource.id), 0) + 1;
276
305
  const resource: ResourceEntry = {
277
306
  id,
package/src/member.ts CHANGED
@@ -26,7 +26,7 @@ export interface MemberUsage {
26
26
  export interface MemberState {
27
27
  attempt?: string;
28
28
  repairs?: number;
29
- repairStopReason?: "tokens" | "time" | "user-stop" | "disposed" | "master-abort";
29
+ repairStopReason?: "tokens" | "turns" | "time" | "user-stop" | "disposed" | "master-abort";
30
30
  name: string;
31
31
  index: number;
32
32
  status: MemberStatus;