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/src/run/solved.ts
CHANGED
|
@@ -32,6 +32,7 @@ function unresolvedCoverageIdeas(run: HordeRun): number[] {
|
|
|
32
32
|
function adoptProposal(run: HordeRun, proposal: SolvedSignal): void {
|
|
33
33
|
run.solved = proposal;
|
|
34
34
|
run.solvedValidatedBy = undefined;
|
|
35
|
+
run.solvedCommitted = false;
|
|
35
36
|
run.solvedContested = false;
|
|
36
37
|
run.solvedVerificationNotice = undefined;
|
|
37
38
|
void recordLifecycle(run, "solved-proposed", { member: proposal.member, text: oneLine(proposal.text, 200) });
|
|
@@ -58,6 +59,7 @@ function dropContested(run: HordeRun, contest: SolvedSignal): void {
|
|
|
58
59
|
if (!run.stopped) clearCleanStop(run.runDir);
|
|
59
60
|
run.solved = undefined;
|
|
60
61
|
run.solvedValidatedBy = undefined;
|
|
62
|
+
run.solvedCommitted = false;
|
|
61
63
|
run.solvedCoverageNotice = undefined;
|
|
62
64
|
run.solvedVerificationNotice = undefined;
|
|
63
65
|
run.solvedContested = true;
|
|
@@ -128,6 +130,7 @@ function commitSolved(
|
|
|
128
130
|
validator: string | undefined,
|
|
129
131
|
): void {
|
|
130
132
|
run.solvedValidatedBy = validator;
|
|
133
|
+
run.solvedCommitted = true;
|
|
131
134
|
void recordLifecycle(run, "solved-validated", { validator });
|
|
132
135
|
safeNotify(
|
|
133
136
|
run,
|
package/src/run/types.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
32
32
|
acceptance?: string;
|
|
33
33
|
snapshotHash?: string;
|
|
34
34
|
repairTokens?: number;
|
|
35
|
+
/** Members whose queued repair work was skipped because the budget could not fund an attempt. */
|
|
36
|
+
repairSkipped?: string[];
|
|
35
37
|
/** True when a user command or session shutdown ended the run. */
|
|
36
38
|
userStop?: boolean | undefined;
|
|
37
39
|
/** Human-readable stop reason for the report, e.g. "stopped by user". */
|
|
@@ -61,6 +63,8 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
61
63
|
solved?: SolvedSignal | undefined;
|
|
62
64
|
/** Peer confirmation of the current proposal, independent of final acceptance. */
|
|
63
65
|
solvedValidatedBy?: string | undefined;
|
|
66
|
+
/** True once the parent wound the run down on the validated proposal. */
|
|
67
|
+
solvedCommitted?: boolean | undefined;
|
|
64
68
|
/** True when the last SOLVED proposal was contested and no validated answer replaced it. */
|
|
65
69
|
solvedContested?: boolean | undefined;
|
|
66
70
|
/** True once the parent has warned that a validated solve still has unverified findings. */
|
package/src/run-graph.ts
CHANGED
|
@@ -242,10 +242,11 @@ function creates(letter: string, kind: GraphNodeKind, repeat: string, links: (dr
|
|
|
242
242
|
return;
|
|
243
243
|
}
|
|
244
244
|
const nodeId = `${letter}${id}`;
|
|
245
|
-
if (addEntity(draft, event, { id: nodeId, kind, label: nodeId, title: ownTitle(input, result) })) {
|
|
245
|
+
if (event.outcome === "applied" && addEntity(draft, event, { id: nodeId, kind, label: nodeId, title: ownTitle(input, result) })) {
|
|
246
246
|
links(draft, nodeId, input);
|
|
247
247
|
return;
|
|
248
248
|
}
|
|
249
|
+
ensureTarget(draft, nodeId);
|
|
249
250
|
addEdge(draft, addMarker(draft, event, `${event.action} (${repeat})`, describe(input, result)), nodeId, repeat, "structure");
|
|
250
251
|
};
|
|
251
252
|
}
|
|
@@ -283,7 +284,8 @@ function solvedHandler(label: string, tone: EdgeTone): Handler {
|
|
|
283
284
|
draft.lastSolved = marker;
|
|
284
285
|
return;
|
|
285
286
|
}
|
|
286
|
-
|
|
287
|
+
const additional = result.confirmation === "additional";
|
|
288
|
+
if (draft.lastSolved) draft.edges.push({ from: marker, to: draft.lastSolved, label: additional ? `${label} (additional)` : label, tone });
|
|
287
289
|
};
|
|
288
290
|
}
|
|
289
291
|
|
|
@@ -493,8 +495,13 @@ function addRefused(draft: Draft, event: HordeEvent): void {
|
|
|
493
495
|
addEdge(draft, marker, refusedTarget(event.action, input), event.action, "negative");
|
|
494
496
|
}
|
|
495
497
|
|
|
498
|
+
/** A no-op that still names a result (a duplicate idea) is a repeat, not a refusal. */
|
|
499
|
+
function isRepeat(event: HordeEvent): boolean {
|
|
500
|
+
return event.outcome === "noop" && record(event.payload.result).noop === true;
|
|
501
|
+
}
|
|
502
|
+
|
|
496
503
|
function addEvent(draft: Draft, event: HordeEvent): void {
|
|
497
|
-
if (event.outcome !== "applied") {
|
|
504
|
+
if (event.outcome !== "applied" && !isRepeat(event)) {
|
|
498
505
|
addRefused(draft, event);
|
|
499
506
|
return;
|
|
500
507
|
}
|
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,6 +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, such as a duplicate idea. */
|
|
34
|
+
noop?: boolean;
|
|
35
|
+
/** For confirm-solved: whether this confirmation validated the proposal or joined an earlier one. */
|
|
36
|
+
confirmation?: "credited" | "additional";
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
interface ActionContext {
|
|
@@ -50,6 +54,20 @@ function requireId(params: HordeToolParams): number {
|
|
|
50
54
|
return params.id;
|
|
51
55
|
}
|
|
52
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
|
+
|
|
53
71
|
function requireRun(ctx: ActionContext): string {
|
|
54
72
|
if (!ctx.runId) throw new Error(`horde: ${ctx.params.action} needs an active run`);
|
|
55
73
|
return ctx.runId;
|
|
@@ -264,6 +282,7 @@ async function ideaAction({ hordeDir, member, params }: ActionContext): Promise<
|
|
|
264
282
|
return {
|
|
265
283
|
text: `Idea ${state}. Build on it instead of adding a duplicate`,
|
|
266
284
|
id: duplicateOf.id,
|
|
285
|
+
noop: true,
|
|
267
286
|
};
|
|
268
287
|
}
|
|
269
288
|
return { text: `Added idea #${idea.id}: ${idea.text}`, id: idea.id };
|
|
@@ -280,6 +299,7 @@ async function claimAction({ hordeDir, member, params }: ActionContext): Promise
|
|
|
280
299
|
|
|
281
300
|
async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
|
|
282
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)");
|
|
283
303
|
const { resource, reused, ownedByCaller } = await claimResource(hordeDir, member, {
|
|
284
304
|
name: params.text,
|
|
285
305
|
note: params.note,
|
|
@@ -338,11 +358,15 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
338
358
|
async function confirmSolvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
339
359
|
const answer = await confirmSolved(ctx.hordeDir, requireRun(ctx), ctx.member, ctx.params.text, ctx.params.revision);
|
|
340
360
|
if (!answer.ok) throw new Error(`horde: ${answer.reason}`);
|
|
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
|
+
}
|
|
341
367
|
return {
|
|
342
|
-
text:
|
|
343
|
-
|
|
344
|
-
? "Validated the SOLVED proposal. Finish your claimed work; the parent will wind down once any coverage requirement is satisfied."
|
|
345
|
-
: `The SOLVED proposal was already validated by ${answer.by}.`,
|
|
368
|
+
text: "Validated the SOLVED proposal. Finish your claimed work; the parent will wind down once any coverage requirement is satisfied.",
|
|
369
|
+
confirmation: "credited",
|
|
346
370
|
};
|
|
347
371
|
}
|
|
348
372
|
|
|
@@ -356,19 +380,20 @@ async function contestSolvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
356
380
|
|
|
357
381
|
async function doneAction(ctx: ActionContext): Promise<ActionResult> {
|
|
358
382
|
const { hordeDir, member, params } = ctx;
|
|
359
|
-
const idea = await finishIdea(hordeDir, member, requireId(params), params
|
|
383
|
+
const idea = await finishIdea(hordeDir, member, requireId(params), closeNote(params));
|
|
360
384
|
return closedIdea(ctx, idea, `Done #${idea.id}: ${idea.text}`);
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
async function dropAction(ctx: ActionContext): Promise<ActionResult> {
|
|
364
|
-
const { hordeDir, member, params } = ctx;
|
|
365
|
-
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) });
|
|
366
391
|
return closedIdea(ctx, idea, `Dropped #${idea.id}: ${idea.text}`);
|
|
367
392
|
}
|
|
368
393
|
|
|
369
394
|
async function ruleOutAction(ctx: ActionContext): Promise<ActionResult> {
|
|
370
395
|
const { hordeDir, member, params } = ctx;
|
|
371
|
-
const idea = await ruleOutIdea(hordeDir, member, requireId(params), params
|
|
396
|
+
const idea = await ruleOutIdea(hordeDir, member, requireId(params), closeNote(params));
|
|
372
397
|
return closedIdea(ctx, idea, `Ruled out #${idea.id}: ${idea.note ?? "dead end"}`);
|
|
373
398
|
}
|
|
374
399
|
|
|
@@ -418,10 +443,11 @@ export async function runHordeAction(
|
|
|
418
443
|
const audit = Boolean(runId && !reads.includes(params.action));
|
|
419
444
|
const intent = audit ? await recordEvent({ ...ctx, requestId: ctx.requestId ? `${ctx.requestId}:intent` : undefined }, params.action, { outcome: "intent", payload: { input: params } }) : undefined;
|
|
420
445
|
try {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
446
|
+
const refused = cleanStop && NEW_WORK_ACTIONS.has(params.action);
|
|
447
|
+
const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop });
|
|
448
|
+
// A refusal and a no-op both record `noop`; the no-op keeps `result.noop` so the graph and the
|
|
449
|
+
// report can tell an absorbed duplicate from refused work.
|
|
450
|
+
if (audit) await recordEvent(ctx, params.action, { outcome: refused || result.noop ? "noop" : "applied", payload: { input: params, result }, parents: intent ? [intent.eventId] : [] });
|
|
425
451
|
return result;
|
|
426
452
|
} catch (error) {
|
|
427
453
|
if (audit) await recordEvent(ctx, params.action, { outcome: "rejected", payload: { input: params, error: String(error) }, parents: intent ? [intent.eventId] : [] });
|
|
@@ -22,12 +22,38 @@ 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
|
+
|
|
50
|
+
/** Name the stale ref and both revisions, so a retry does not have to guess which evidence to replace. */
|
|
51
|
+
function staleEvidence(state: CollaborationState, refs: string[], target: string, revision: string): string | undefined {
|
|
52
|
+
const stale = refs.map((id) => state.evidence.find((e) => e.id === id)).find((e) => e && (e.target !== target || e.revision !== revision));
|
|
53
|
+
if (!stale) return undefined;
|
|
54
|
+
return `${stale.id} is recorded against ${stale.target}@${stale.revision.slice(0, 12)}; the current target is ${target}@${revision.slice(0, 12)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
function newMessage(c: CollaborationCommand): EventDraft {
|
|
32
58
|
const { ctx, state, params: p, seq } = c;
|
|
33
59
|
const target = requireParam(p.target, "target", p.action);
|
|
@@ -36,6 +62,8 @@ function newMessage(c: CollaborationCommand): EventDraft {
|
|
|
36
62
|
const task = path.join(ctx.hordeDir, "runs", ctx.runId, "TASK.md");
|
|
37
63
|
const count = fs.existsSync(task) ? Number(/^Members: (\d+)/m.exec(fs.readFileSync(task, "utf8"))?.[1]) : 0;
|
|
38
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`);
|
|
39
67
|
if (p.revision && p.revision !== info.revision) throw new Error("horde: target changed; inspect current revision");
|
|
40
68
|
const message: PeerMessage = {
|
|
41
69
|
id: `Q${seq}`, sender: ctx.actor, to, target, revision: info.revision, kind: p.kind ?? "question",
|
|
@@ -49,10 +77,14 @@ function transition(c: CollaborationCommand): EventDraft {
|
|
|
49
77
|
const { ctx, state, params: p } = c;
|
|
50
78
|
const m = state.messages.find((entry) => entry.id === p.target);
|
|
51
79
|
if (!m) throw new Error("horde: unknown message Q#");
|
|
52
|
-
if (m.state === "resolved"
|
|
80
|
+
if (m.state === "resolved") throw new Error("horde: message is already closed; create a new request");
|
|
81
|
+
// A deferred request is disclosed debt, not a closed thread: the recipient may still answer it
|
|
82
|
+
// and the sender may still resolve it. Otherwise a blocking challenge deferred before any
|
|
83
|
+
// response could never be cleared by anyone.
|
|
84
|
+
if (m.state === "deferred" && p.action !== "resolve" && p.action !== "respond") throw new Error("horde: a deferred request takes a response from its recipient or a resolution from its sender");
|
|
53
85
|
const closing = p.action === "resolve" || p.action === "defer";
|
|
54
86
|
if (closing ? ctx.actor !== m.sender : ctx.actor !== m.to) throw new Error("horde: recipient acknowledges/responds; sender resolves/defers after review");
|
|
55
|
-
|
|
87
|
+
// A direct answer shows the request was seen: `ack` only signals intent, so `respond` implies it.
|
|
56
88
|
const text = requireText(p.text, p.action);
|
|
57
89
|
const refs = evidenceRefs(state, p.refs);
|
|
58
90
|
if (p.action === "resolve" && m.blocking) validateResolution(c, m, refs);
|
|
@@ -74,17 +106,35 @@ function responseSubject(c: CollaborationCommand, m: PeerMessage): string {
|
|
|
74
106
|
throw new Error("horde: responseTarget must be the challenged target, its successor, or a finding attached to the challenged idea");
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* A blocking request closes on the recipient's response plus the sender's own supporting check of
|
|
111
|
+
* the current revision. Before any response the sender may still withdraw its own challenge on an
|
|
112
|
+
* F# or A#: a challenger that verified the claim itself, or watched others verify it, records a
|
|
113
|
+
* supporting review of the current revision and cites evidence on it. Limits that review
|
|
114
|
+
* discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
|
|
115
|
+
*/
|
|
77
116
|
function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: string[]): void {
|
|
78
|
-
if (!["responded", "deferred"].includes(m.state) || !m.responseRevision || refs.length === 0) throw new Error("horde: blocking resolution needs a response and evidence");
|
|
79
117
|
if (m.sender === m.to) throw new Error("horde: a blocking request needs an independent resolver");
|
|
118
|
+
const withdrawal = m.responseRevision === undefined;
|
|
80
119
|
const target = responseSubject(c, m);
|
|
120
|
+
const reviewable = /^[AF]\d+$/.test(target);
|
|
121
|
+
if (refs.length === 0 || (withdrawal && !reviewable) || (!withdrawal && !["responded", "deferred"].includes(m.state))) {
|
|
122
|
+
throw new Error("horde: blocking resolution needs a response and evidence");
|
|
123
|
+
}
|
|
81
124
|
const revision = targetInfo(c.ctx, c.state, target).revision;
|
|
82
|
-
if ((c.params.revision ?? m.responseRevision) !== revision) throw new Error(`horde: response is stale; explicitly review and resolve ${target}@${revision}, or ask the owner to respond again`);
|
|
83
|
-
if (!refs.some((id) => c.state.evidence.some((e) => e.id === id && e.target === target && e.revision === revision)))
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!review || review.verdict !== "supports" || (target.startsWith("A") && review.unchecked)) throw new Error("horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
|
|
125
|
+
if ((c.params.revision ?? m.responseRevision ?? revision) !== revision) throw new Error(`horde: response is stale; explicitly review and resolve ${target}@${revision}, or ask the owner to respond again`);
|
|
126
|
+
if (!refs.some((id) => c.state.evidence.some((e) => e.id === id && e.target === target && e.revision === revision))) {
|
|
127
|
+
const detail = staleEvidence(c.state, refs, target, revision) ?? `none of ${refs.join(", ")} targets ${target}@${revision.slice(0, 12)}`;
|
|
128
|
+
throw new Error(`horde: resolution evidence must address the current response target revision: ${detail}; attach evidence to that revision first`);
|
|
87
129
|
}
|
|
130
|
+
if (!reviewable) return;
|
|
131
|
+
const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === c.ctx.actor);
|
|
132
|
+
if (!review || review.verdict !== "supports") {
|
|
133
|
+
throw new Error(withdrawal
|
|
134
|
+
? `horde: no response yet; to withdraw your own challenge, record a supporting review of ${target}@${revision.slice(0, 12)} and cite evidence on it`
|
|
135
|
+
: "horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
|
|
136
|
+
}
|
|
137
|
+
if (!withdrawal && target.startsWith("A") && review.unchecked) throw new Error("horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
|
|
88
138
|
}
|
|
89
139
|
|
|
90
140
|
function attachEvidence(c: CollaborationCommand): EventDraft {
|
|
@@ -132,7 +182,8 @@ function recordReview(c: CollaborationCommand): EventDraft {
|
|
|
132
182
|
const refs = [...evidenceRefs(state, p.refs)];
|
|
133
183
|
if (!refs.length && !p.evidenceText) throw new Error("horde: attach evidence before reviewing or supply evidenceText for an atomic evidence+review");
|
|
134
184
|
if (!p.verdict || p.unchecked === undefined) throw new Error("horde: review needs verdict and unchecked (empty only for full coverage)");
|
|
135
|
-
|
|
185
|
+
const stale = staleEvidence(state, refs, target, info.revision);
|
|
186
|
+
if (stale) throw new Error(`horde: review evidence must address this exact target revision: ${stale}; attach fresh evidence to the current revision or pass evidenceText`);
|
|
136
187
|
const text = requireText(p.text, "review");
|
|
137
188
|
const checked = requireParam(p.checked, "checked", p.action);
|
|
138
189
|
const evidenceRecord = p.evidenceText === undefined ? undefined : {
|
|
@@ -144,13 +195,36 @@ function recordReview(c: CollaborationCommand): EventDraft {
|
|
|
144
195
|
return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review: { id: `V${seq}`, actor: ctx.actor, target, revision: info.revision,
|
|
145
196
|
verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
|
|
146
197
|
text, evidence: refs },
|
|
147
|
-
...completeAssignedReview(c, target, text),
|
|
148
|
-
...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {})
|
|
198
|
+
...completeAssignedReview(c, target, `V${seq}`, 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
|
+
})) };
|
|
149
222
|
}
|
|
150
223
|
|
|
151
|
-
|
|
224
|
+
/** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
|
|
225
|
+
function completeAssignedReview(c: CollaborationCommand, target: string, reviewId: string, text: string, refs: string[]): Record<string, unknown> {
|
|
152
226
|
const assignment = openMessages(c.state).find((m) => m.sender === "parent" && m.to === c.ctx.actor && m.kind === "review-request" && m.target === target);
|
|
153
|
-
return assignment ? { transition: { id: assignment.id, state: "resolved", text: `Review recorded: ${text}`, revision: c.params.revision } } : {};
|
|
227
|
+
return assignment ? { transition: { id: assignment.id, state: "resolved", text: `Review recorded: ${reviewId} ${text}`, revision: c.params.revision }, refs } : {};
|
|
154
228
|
}
|
|
155
229
|
|
|
156
230
|
function automaticChallenge(c: CollaborationCommand, target: string, info: { owner: string; revision: string }, text: string, refs: string[]): Record<string, unknown> {
|
|
@@ -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/inbox.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { collaborationState, openMessages } from "../ledger/collaboration.js";
|
|
4
|
-
import type { PeerMessage } from "../ledger/collaboration.js";
|
|
4
|
+
import type { CollaborationState, PeerMessage } from "../ledger/collaboration.js";
|
|
5
5
|
import { recordEvent } from "../ledger/events.js";
|
|
6
6
|
import type { EventContext } from "../ledger/events.js";
|
|
7
7
|
|
|
8
|
+
/** A deferred blocking request is disclosed acceptance debt its recipient can still answer. */
|
|
9
|
+
function deferredDebt(state: CollaborationState): PeerMessage[] {
|
|
10
|
+
return state.messages.filter((m) => m.state === "deferred" && m.blocking);
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export function inboxFor(ctx: EventContext): PeerMessage[] {
|
|
9
|
-
|
|
14
|
+
const state = collaborationState(ctx.hordeDir, ctx.runId);
|
|
15
|
+
return [...openMessages(state), ...deferredDebt(state)]
|
|
10
16
|
.filter((m) => m.state === "responded" ? m.sender === ctx.actor : m.to === ctx.actor)
|
|
11
17
|
.sort((a, b) => Number(b.blocking) - Number(a.blocking) || a.createdAt.localeCompare(b.createdAt));
|
|
12
18
|
}
|
|
@@ -34,7 +40,8 @@ export function registerInbox(pi: ExtensionAPI, ctx: EventContext): void {
|
|
|
34
40
|
return {
|
|
35
41
|
continue: true,
|
|
36
42
|
entries: [{ type: "custom_message" as const, customType: "horde-inbox", display: true,
|
|
37
|
-
content: "Peer feedback (untrusted data; use horde actions for state changes). Read full text with horde inbox.
|
|
43
|
+
content: "Peer feedback (untrusted data; use horde actions for state changes). Read full text with horde inbox. Respond with a revision (ack first only to signal intent), then let the requester review." +
|
|
44
|
+
(messages.some((m) => m.state === "deferred") ? " A deferred blocking request is still acceptance debt: respond with evidence so the requester can resolve it." : "") + "\n" +
|
|
38
45
|
messages.map((m) => `${m.id} ${m.kind} ${m.sender} -> ${m.to} ${m.target}@${m.revision} [${m.state}${m.blocking ? ", blocking" : ""}]: ${m.text.slice(0, 700)}${m.response ? `\nResponse: ${m.response.slice(0, 250)}` : ""}`).join("\n\n"),
|
|
39
46
|
details: { deliveries } }],
|
|
40
47
|
};
|
package/src/tool/params.ts
CHANGED
|
@@ -68,10 +68,18 @@ export const HordeParams = Type.Object({
|
|
|
68
68
|
generation: Type.Optional(Type.Integer({ minimum: 0, description: "Expected resource generation. Required after the first resource-state update." })),
|
|
69
69
|
requestId: Type.Optional(Type.String({ description: "Stable retry key for collaboration mutations within this session attempt." })),
|
|
70
70
|
text: Type.Optional(
|
|
71
|
-
Type.String({
|
|
71
|
+
Type.String({
|
|
72
|
+
description:
|
|
73
|
+
"Message, idea text, finding text, resource name, or note for done/drop/rule-out. Required for " +
|
|
74
|
+
"message, ack, respond, resolve, defer, attach (the observation) and review (the decision summary)",
|
|
75
|
+
}),
|
|
72
76
|
),
|
|
73
77
|
note: Type.Optional(
|
|
74
|
-
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
|
+
}),
|
|
75
83
|
),
|
|
76
84
|
cleanup: Type.Optional(
|
|
77
85
|
Type.String({
|