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