agent-horde 0.1.2 → 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 +11 -4
- package/docs/REFERENCE.md +15 -7
- package/package.json +1 -1
- package/src/citations.ts +86 -7
- package/src/collaboration-report.ts +19 -3
- package/src/dashboard-data.ts +16 -4
- package/src/dashboard-html.ts +20 -5
- package/src/ledger/collaboration.ts +10 -1
- package/src/ledger/event-metrics.ts +34 -0
- package/src/ledger/resources.ts +29 -0
- package/src/member.ts +1 -1
- package/src/prompt.ts +70 -15
- package/src/run/finalize.ts +40 -15
- package/src/run/repair.ts +124 -34
- package/src/run/solved.ts +3 -0
- package/src/run/types.ts +4 -0
- package/src/run-graph.ts +9 -3
- package/src/tool/actions.ts +10 -8
- package/src/tool/collaboration-actions.ts +43 -12
- package/src/tool/inbox.ts +10 -3
- package/src/tool/params.ts +5 -1
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.
|
|
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/`.
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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.
|
|
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` |
|
|
238
|
-
| `PI_HORDE_REPAIR_TOKENS` |
|
|
239
|
-
| `PI_HORDE_REPAIR_MIN_TOKENS` |
|
|
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
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 {
|
|
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
|
|
242
|
-
|
|
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
|
|
247
|
-
"without
|
|
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(
|
|
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}`),
|
package/src/dashboard-data.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
package/src/dashboard-html.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
619
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ledger/resources.ts
CHANGED
|
@@ -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;
|