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 +11 -4
- package/docs/REFERENCE.md +19 -7
- package/package.json +1 -1
- package/src/citations.ts +98 -11
- package/src/collaboration-report.ts +23 -3
- package/src/dashboard-data.ts +16 -4
- package/src/dashboard-html.ts +20 -5
- package/src/ledger/clusters.ts +3 -2
- package/src/ledger/collaboration.ts +23 -1
- package/src/ledger/event-metrics.ts +35 -0
- package/src/ledger/ideas.ts +11 -3
- package/src/ledger/resources.ts +29 -0
- package/src/ledger/run-signals.ts +5 -1
- package/src/member.ts +1 -1
- package/src/prompt.ts +80 -22
- package/src/run/finalize.ts +40 -15
- package/src/run/repair.ts +156 -41
- package/src/run/solved.ts +3 -0
- package/src/run/types.ts +4 -0
- package/src/run-graph.ts +10 -3
- package/src/tool/actions.ts +39 -13
- package/src/tool/collaboration-actions.ts +87 -13
- package/src/tool/collaboration-target.ts +6 -1
- package/src/tool/inbox.ts +10 -3
- package/src/tool/params.ts +10 -2
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.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/`.
|
|
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,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
|
-
|
|
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.
|
|
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` |
|
|
238
|
-
| `PI_HORDE_REPAIR_TOKENS` |
|
|
239
|
-
| `PI_HORDE_REPAIR_MIN_TOKENS` |
|
|
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
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";
|
|
@@ -72,7 +73,10 @@ export function citedDataFiles(text: string): string[] {
|
|
|
72
73
|
return [...out];
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
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
|
|
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
|
|
242
|
-
|
|
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
|
|
247
|
-
"without
|
|
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(
|
|
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}`),
|
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
|
}
|
package/src/ledger/clusters.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/ledger/ideas.ts
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
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);
|
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,
|
|
@@ -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)
|
|
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"),
|