agent-horde 0.1.2 → 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.2
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
@@ -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,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
- 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.
41
44
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
42
45
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
43
46
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -83,7 +86,7 @@ Every page has a JSON twin under `/api/<same path>`.
83
86
  | `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
84
87
  | `/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
88
  | `/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 |
89
+ | `/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
90
 
88
91
  Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
89
92
  the session files, so they show end cause, turns, and usage but no activity.
@@ -175,9 +178,16 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
175
178
  `reproduced` review from a non-owner. A `document` artifact (.md, .txt, .rst, .adoc, .pdf)
176
179
  accepts a full-scope `sourced` or `derived` review against its criteria. Changing criteria or
177
180
  dependencies needs changed bytes and a fresh review. Open blockers and challenges block
178
- acceptance; `defer` keeps the debt visible.
181
+ acceptance; `defer` keeps the debt visible, the recipient can still `respond` to a deferred
182
+ request, and the requester then resolves it. A challenger can withdraw its own blocking
183
+ challenge on an F# or A# before any response by recording a supporting review of the current
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#`.
179
188
  - **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
189
+ `PI_HORDE_INBOX_TURNS` extra turns per session. A recipient answers with `respond`; `ack` is an
190
+ optional first step that signals intent. Delivery is a context insertion, not
181
191
  comprehension. Message and review bodies are untrusted input, not tool instructions.
182
192
  - **Reminders.** Every `PI_HORDE_REMIND_CALLS` tool calls or `PI_HORDE_REMIND_MS`, the tool
183
193
  result restates the task, role, and claimed ideas. A member that finishes its last idea while
@@ -234,9 +244,11 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
234
244
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
235
245
  | `PI_HORDE_ALLOW_UNVERIFIED` | unset | `1` accepts a validated answer with unverified findings |
236
246
  | `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 |
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 |
249
+ | `PI_HORDE_REPAIR_MIN_TOKENS` | 40000 | token floor per attempt; below it scheduling stops with a `repair-skipped` event |
250
+ | `PI_HORDE_REPAIR_MIN_MS` | 60000 | time floor per attempt; the clock is split only among attempts the token budget can fund |
251
+ | `PI_HORDE_REPAIR_TURNS` | 40 | turn cap per attempt; an attempt still reading its inbox at the cap is stopped with `turns` |
240
252
  | `PI_HORDE_DELIVERABLE` | on | `off` skips the synthesis member |
241
253
  | `PI_HORDE_POSTMORTEM` | on | `off` skips the automatic post-mortem |
242
254
  | `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.2",
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
@@ -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";
@@ -72,7 +73,10 @@ export function citedDataFiles(text: string): string[] {
72
73
  return [...out];
73
74
  }
74
75
 
75
- /** 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. */
76
80
  function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
77
81
  let entries: fs.Dirent[];
78
82
  try {
@@ -82,7 +86,7 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
82
86
  }
83
87
  for (const entry of entries) {
84
88
  if (entry.isFile() && entry.name === name) return true;
85
- 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)) {
86
90
  return true;
87
91
  }
88
92
  }
@@ -93,7 +97,8 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
93
97
  * True when a cited file cannot be resolved to an existing file on disk. Paths
94
98
  * are tried against the repo root, the run directory, its artifacts directory,
95
99
  * and the directory of the citing file. Because generated artifacts usually cite
96
- * 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
97
102
  * also counts, which keeps the warning precise instead of noisy.
98
103
  */
99
104
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
@@ -109,7 +114,7 @@ function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean
109
114
  }
110
115
  }
111
116
  const base = ref.replace(/^\.\//, "").split("/").pop() ?? ref;
112
- return !fileNamedUnder(run.runDir, base);
117
+ return !fileNamedUnder(run.runDir, base) && !fileNamedUnder(run.cwd, base);
113
118
  }
114
119
 
115
120
  export function resourceCitations(text: string): number[] {
@@ -215,6 +220,61 @@ export function ledgerCitationWarnings(
215
220
  return [...new Set(warnings)];
216
221
  }
217
222
 
223
+ /** The acceptance vocabulary for a finding nobody reviewed at its current revision. */
224
+ const UNREVIEWED_LABEL = /unverified|unreviewed|no (?:independent|current-revision) review|not (?:independently )?reviewed/i;
225
+
226
+ /**
227
+ * True when the text cites F<id> and no citing paragraph carries the unreviewed label. One label
228
+ * at the top of a document cannot cover every later citation; a label next to any citation can.
229
+ */
230
+ function citedWithoutLabel(text: string, id: number): boolean {
231
+ const cite = new RegExp(`\\bF${id}\\b`);
232
+ const citing = text.split(/\n\s*\n/).filter((paragraph) => cite.test(paragraph));
233
+ return citing.length > 0 && !citing.some((paragraph) => UNREVIEWED_LABEL.test(paragraph));
234
+ }
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
+
239
+ /**
240
+ * The units a review citation is judged in: sentences, except that each list item and table row
241
+ * stands alone. A bullet that cites reviews as the source of a limitation does not claim them as
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.
244
+ */
245
+ function citationUnits(text: string): string[] {
246
+ return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : paragraph.split(SENTENCE_BREAK)));
247
+ }
248
+
249
+ /**
250
+ * A V# cited next to an F# must review that finding at its current revision. A review of the
251
+ * finding it superseded, of an artifact that merely depends on it, or of an earlier revision is
252
+ * not support for the claim it sits next to. A V# with no review record is left alone: "V8" in
253
+ * prose is an engine, not a citation.
254
+ */
255
+ function reviewCitationWarnings(label: string, text: string, findings: FindingEntry[], reviews: ReviewRecord[]): string[] {
256
+ const warnings: string[] = [];
257
+ for (const paragraph of citationUnits(text)) {
258
+ const cited = [...new Set([...paragraph.matchAll(/\bF(\d+)\b/g)].map((match) => `F${match[1]}`))];
259
+ if (cited.length === 0) continue;
260
+ const targets = new Set([...cited, ...[...paragraph.matchAll(/\bA(\d+)\b/g)].map((match) => `A${match[1]}`)]);
261
+ for (const match of paragraph.matchAll(/\bV(\d+)\b/g)) {
262
+ const id = `V${match[1]}`;
263
+ const review = reviews.find((entry) => entry.id === id);
264
+ if (!review) continue;
265
+ if (!targets.has(review.target)) {
266
+ warnings.push(`${label} cites ${id} next to ${cited.join(", ")}, but ${id} reviews ${review.target}`);
267
+ continue;
268
+ }
269
+ const finding = findings.find((entry) => `F${entry.id}` === review.target);
270
+ if (finding && review.revision !== findingRevision(finding)) {
271
+ warnings.push(`${label} cites ${id} for ${review.target}, but it reviewed an earlier revision; the current revision has no such review`);
272
+ }
273
+ }
274
+ }
275
+ return warnings;
276
+ }
277
+
218
278
  export function generatedArtifactWarnings(
219
279
  run: HordeRun,
220
280
  label: string,
@@ -238,14 +298,41 @@ export function generatedArtifactWarnings(
238
298
  const cited = checks.filter((f) => f.check.status === status && new RegExp(`\\bF${f.id}\\b`).test(text));
239
299
  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
300
  }
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)) {
301
+ const unlabeled = unverified.filter((finding) => citedWithoutLabel(text, finding.id));
302
+ if (unlabeled.length > 0) {
245
303
  warnings.push(
246
- `${label} cites unverified findings (${citedUnverified.map((finding) => `F${finding.id}`).join(", ")}) ` +
247
- "without labeling them unverified",
304
+ `${label} cites findings with no independent review (${unlabeled.map((finding) => `F${finding.id}`).join(", ")}) ` +
305
+ "without saying so in the citing paragraph",
248
306
  );
249
307
  }
308
+ warnings.push(...reviewCitationWarnings(label, text, findings, state.reviews));
250
309
  return [...new Set(warnings)];
251
310
  }
311
+
312
+ /**
313
+ * Non-owner members whose findings, evidence, reviews or leases name a resource. A member that
314
+ * works in a shared lab rarely re-runs `horde resource`, so explicit reclaims alone undercount reuse.
315
+ */
316
+ export function resourceCitingMembers(
317
+ resources: ResourceEntry[],
318
+ findings: FindingEntry[],
319
+ state: Pick<CollaborationState, "evidence" | "reviews" | "resources">,
320
+ ): Map<number, string[]> {
321
+ const texts: Array<{ member: string; text: string }> = [
322
+ ...findings.map((finding) => ({ member: finding.author, text: finding.body })),
323
+ ...state.evidence.map((record) => ({ member: record.actor, text: record.text })),
324
+ ...state.reviews.map((review) => ({ member: review.actor, text: `${review.checked} ${review.text}` })),
325
+ ];
326
+ const out = new Map<number, string[]>();
327
+ for (const resource of resources) {
328
+ const members = new Set<string>();
329
+ for (const { member, text } of texts) {
330
+ if (member !== resource.owner && resourceCitations(text).includes(resource.id)) members.add(member);
331
+ }
332
+ for (const contract of state.resources) {
333
+ if (contract.target === `R${resource.id}` && contract.owner !== resource.owner) members.add(contract.owner);
334
+ }
335
+ out.set(resource.id, [...members].sort());
336
+ }
337
+ return out;
338
+ }
@@ -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";
@@ -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";
@@ -84,19 +88,35 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
84
88
  unresolved: openMessages(state).length, blocking: blockingMessages(state).length,
85
89
  resolvedChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "resolved").length,
86
90
  deferredChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "deferred").length,
91
+ openChallenges: messages.filter((m) => m.kind === "challenge" && m.state !== "resolved" && m.state !== "deferred").length,
87
92
  meanDeliveryMs: delays.length ? Math.round(delays.reduce((a, b) => a + b, 0) / delays.length) : 0,
88
93
  acceptedArtifacts: acceptedArtifacts.length,
89
94
  acceptedDocuments: acceptedDocuments.length, acceptedRunnableArtifacts: acceptedRunnableArtifacts.length,
90
95
  } };
91
96
  }
92
97
 
98
+ function withEvidence(text: string, evidence: string[] | undefined): string {
99
+ return evidence?.length ? `${text} (evidence ${evidence.join(", ")})` : text;
100
+ }
101
+
102
+ /** The response and the resolution are different actors' decisions; each shows with its own evidence. */
103
+ function messageMarkdown(m: PeerMessage): string {
104
+ const lines = [
105
+ `- ${m.id} ${m.sender} -> ${m.to} ${m.target}@${m.revision} [${m.state}] ${m.kind}${m.blocking ? " BLOCKING" : ""}`,
106
+ ` ${m.text}`,
107
+ ` Response: ${m.response ? withEvidence(m.response, m.responseEvidence) : "pending"}`,
108
+ ];
109
+ if (m.resolution !== undefined) lines.push(` ${m.state === "deferred" ? "Deferred" : "Resolution"}: ${withEvidence(m.resolution, m.resolutionEvidence)}`);
110
+ lines.push(` Evidence: ${m.evidence.join(", ") || "none"}; created ${m.createdAt}; updated ${m.updatedAt}`);
111
+ return lines.join("\n");
112
+ }
113
+
93
114
  function collaborationMarkdown(report: CollaborationReport): string {
94
115
  const { state } = report;
95
116
  return [
96
117
  `# Collaboration ${report.runId}`, `\nEvent high-water mark: ${report.sequence}. Delivery records context insertion, not comprehension.`,
97
118
  "\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}`),
119
+ "\n## Questions, challenges and handoffs", ...state.messages.map(messageMarkdown),
100
120
  "\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
121
  "\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
122
  "\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
  }
@@ -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[] = [];
@@ -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 {
@@ -96,14 +100,28 @@ export function projectCollaboration(events: HordeEvent[]): CollaborationState {
96
100
  state.resources = state.resources.filter((r) => r.target !== contract.target).concat(contract);
97
101
  }
98
102
  if (p.transition) applyTransition(state, event);
103
+ if (Array.isArray(p.clearances)) applyClearances(state, event);
99
104
  }
100
105
  return state;
101
106
  }
102
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
+
103
120
  function applyTransition(state: CollaborationState, event: HordeEvent): void {
104
121
  const t = event.payload.transition as { id: string; state: MessageState; text?: string; revision?: string; responseTarget?: string; attempt?: string };
105
122
  const message = state.messages.find((m) => m.id === t.id);
106
123
  if (!message) return;
124
+ const refs = Array.isArray(event.payload.refs) ? (event.payload.refs as string[]) : [];
107
125
  if (t.state === "delivered") {
108
126
  message.deliveries[t.attempt ?? event.attempt] = event.at;
109
127
  if (message.state === "pending") message.state = "delivered";
@@ -113,7 +131,11 @@ function applyTransition(state: CollaborationState, event: HordeEvent): void {
113
131
  message.response = t.text;
114
132
  message.responseRevision = t.revision;
115
133
  message.responseTarget = t.responseTarget;
116
- } else if (t.state === "resolved" || t.state === "deferred") message.resolution = t.text;
134
+ message.responseEvidence = refs;
135
+ } else if (t.state === "resolved" || t.state === "deferred") {
136
+ message.resolution = t.text;
137
+ message.resolutionEvidence = refs;
138
+ }
117
139
  }
118
140
  message.updatedAt = event.at;
119
141
  }
@@ -0,0 +1,35 @@
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 who confirmed the same proposal after the credited one; their text is in the event. */
7
+ additional: string[];
8
+ }
9
+
10
+ /** The report names every member that confirmed the proposal, not only the one credited. */
11
+ export function solvedConfirmations(events: HordeEvent[]): SolvedConfirmations {
12
+ const out: SolvedConfirmations = { credited: [], additional: [] };
13
+ for (const event of events) {
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);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ export function ideaCreationCount(events: HordeEvent[]): number {
23
+ return events.filter((event) => event.action === "idea" && event.outcome === "applied").length;
24
+ }
25
+
26
+ /** Idea creations the dedup folded into an existing idea, by that idea's id, most absorbed first. */
27
+ export function absorbedIdeaCreations(events: HordeEvent[]): Array<{ id: number; count: number }> {
28
+ const counts = new Map<number, number>();
29
+ for (const event of events) {
30
+ if (event.action !== "idea" || event.outcome !== "noop") continue;
31
+ const id = (event.payload.result as { id?: unknown } | undefined)?.id;
32
+ if (typeof id === "number") counts.set(id, (counts.get(id) ?? 0) + 1);
33
+ }
34
+ return [...counts].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count || a.id - b.id);
35
+ }
@@ -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);
@@ -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,
@@ -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"),