agent-horde 0.1.5 → 0.2.0
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 +42 -10
- package/docs/REFERENCE.md +141 -27
- package/package.json +1 -1
- package/src/args.ts +125 -50
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +390 -95
- package/src/dashboard.ts +205 -96
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +62 -3
- package/src/prompt.ts +102 -21
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +32 -15
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +30 -21
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +36 -25
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +77 -10
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +70 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +16 -9
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +206 -41
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +13 -6
- package/src/widget.ts +80 -8
|
@@ -3,15 +3,15 @@ import * as path from "node:path";
|
|
|
3
3
|
import { listFindingsForRun } from "../ledger/findings.js";
|
|
4
4
|
import { requireParam, requireText } from "../ledger/files.js";
|
|
5
5
|
import { collaborationState, openMessages, projectCollaboration } from "../ledger/collaboration.js";
|
|
6
|
-
import type { CollaborationState, PeerMessage } from "../ledger/collaboration.js";
|
|
7
|
-
import { transactEvent } from "../ledger/events.js";
|
|
6
|
+
import type { ArtifactRecord, CollaborationState, PeerMessage } from "../ledger/collaboration.js";
|
|
7
|
+
import { readEvents, transactEvent } from "../ledger/events.js";
|
|
8
8
|
import type { EventContext, EventDraft } from "../ledger/events.js";
|
|
9
9
|
import { currentTarget, targetInfo, retainArtifact, hashText } from "./collaboration-target.js";
|
|
10
10
|
import { collaborationView, inspectRecord } from "./collaboration-view.js";
|
|
11
11
|
import { resourceTransition } from "./resource-contract.js";
|
|
12
12
|
import type { HordeToolParams } from "./params.js";
|
|
13
13
|
|
|
14
|
-
export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state", "collaboration", "inspect"]);
|
|
14
|
+
export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect"]);
|
|
15
15
|
interface CollaborationCommand {
|
|
16
16
|
ctx: EventContext;
|
|
17
17
|
state: CollaborationState;
|
|
@@ -144,9 +144,13 @@ function attachEvidence(c: CollaborationCommand): EventDraft {
|
|
|
144
144
|
if (p.revision && p.revision !== info.revision) throw new Error("horde: target revision changed");
|
|
145
145
|
const file = p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {};
|
|
146
146
|
const text = requireText(p.text, "attach");
|
|
147
|
+
// Contradicting evidence on an idea is how a lead dies; the record that outlives the run is a rule-out.
|
|
148
|
+
const ruleOutHint = p.relation === "contradicts" && /^I\d+$/.test(target)
|
|
149
|
+
? { note: `${target} has contradicting evidence: a rule-out candidate. If this closes the lead, its owner (${info.owner}) should 'horde rule-out' it with this evidence as the reason so later runs do not retry it.` }
|
|
150
|
+
: {};
|
|
147
151
|
return { payload: { evidenceRecord: { id: `E${seq}`, actor: ctx.actor, target, revision: info.revision,
|
|
148
152
|
relation: p.relation ?? "extends", text, ...file },
|
|
149
|
-
...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}) } };
|
|
153
|
+
...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}), ...ruleOutHint } };
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
function publishArtifact(c: CollaborationCommand): EventDraft {
|
|
@@ -167,8 +171,15 @@ function publishArtifact(c: CollaborationCommand): EventDraft {
|
|
|
167
171
|
if (existing && existing.hash === retained.hash && (existing.criteria !== criteria || JSON.stringify(existing.dependencies) !== JSON.stringify(dependencies))) {
|
|
168
172
|
throw new Error("horde: changing criteria or dependencies requires a new file revision so prior reviews cannot transfer to a different contract");
|
|
169
173
|
}
|
|
174
|
+
// A revision other artifacts pin is a frozen contract: re-hashing it goes stale for every
|
|
175
|
+
// dependent, so the change is announced, and reviewers can reattest when behaviour is unchanged.
|
|
176
|
+
const dependents = existing ? state.artifacts.filter((a) => a.id !== existing.id && a.dependencies.some((d) => d.target === existing.id)).map((a) => a.id) : [];
|
|
177
|
+
if (existing && existing.hash !== retained.hash && dependents.length && !p.text?.trim()) {
|
|
178
|
+
throw new Error(`horde: ${existing.id}@${existing.hash.slice(0, 12)} is pinned by ${dependents.join(", ")}; prefer a follow-up finding or artifact, or republish with text= naming the change and whether behaviour is unchanged so dependents can reattest`);
|
|
179
|
+
}
|
|
180
|
+
const changeNote = existing && existing.hash !== retained.hash && p.text?.trim() ? { changeNote: p.text.trim() } : {};
|
|
170
181
|
return { payload: { artifact: { id: existing?.id ?? `A${seq}`, kind, owner: ctx.actor, ...retained,
|
|
171
|
-
criteria, dependencies } }, parents: evidenceRefs(state, p.refs) };
|
|
182
|
+
criteria, dependencies, ...changeNote }, ...(dependents.length ? { dependents } : {}) }, parents: evidenceRefs(state, p.refs) };
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
function recordReview(c: CollaborationCommand): EventDraft {
|
|
@@ -192,33 +203,71 @@ function recordReview(c: CollaborationCommand): EventDraft {
|
|
|
192
203
|
text: requireParam(p.evidenceText, "evidenceText", p.action), ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
|
|
193
204
|
};
|
|
194
205
|
if (evidenceRecord) refs.push(evidenceRecord.id);
|
|
206
|
+
const limits = p.limits?.trim();
|
|
207
|
+
// A partial verdict refutes nothing: its unchecked scope is coverage debt that the acceptance
|
|
208
|
+
// names and a full review settles. Only a refutation is an objection the owner must answer.
|
|
195
209
|
return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review: { id: `V${seq}`, actor: ctx.actor, target, revision: info.revision,
|
|
196
|
-
verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
|
|
210
|
+
verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked, ...(limits ? { limits } : {}),
|
|
197
211
|
text, evidence: refs },
|
|
198
212
|
...completeAssignedReview(c, target, `V${seq}`, text, refs),
|
|
199
|
-
...(
|
|
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");
|
|
213
|
+
...(p.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
/**
|
|
209
|
-
* A
|
|
210
|
-
*
|
|
211
|
-
*
|
|
217
|
+
* A behaviour-neutral re-hash (a republish against a corrected dependency, an amended finding
|
|
218
|
+
* body, a spec clarification) would otherwise drop every review of the earlier revision. The
|
|
219
|
+
* reviewer carries its own latest review forward with evidence that what it checked is unchanged.
|
|
220
|
+
*/
|
|
221
|
+
/**
|
|
222
|
+
* What changed between the reviewed revision and the current one, as the ledger recorded it. A
|
|
223
|
+
* review carries forward only across prose: a new acceptance contract or a new claim line is a
|
|
224
|
+
* different subject and needs a fresh review.
|
|
212
225
|
*/
|
|
213
|
-
function
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
function reattestBlocker(ctx: EventContext, state: CollaborationState, target: string, previousRevision: string): string | undefined {
|
|
227
|
+
const events = readEvents(ctx.hordeDir, ctx.runId).filter((e) => e.outcome === "applied");
|
|
228
|
+
if (target.startsWith("A")) {
|
|
229
|
+
const current = state.artifacts.find((a) => a.id === target);
|
|
230
|
+
const earlier = events.map((e) => e.payload.artifact as ArtifactRecord | undefined).find((a) => a?.id === target && a.hash === previousRevision);
|
|
231
|
+
if (!current || !earlier) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not on record`;
|
|
232
|
+
if (earlier.criteria !== current.criteria) return `${target}'s criteria changed since the reviewed revision; review the new contract`;
|
|
233
|
+
if ((earlier.kind ?? "runnable") !== (current.kind ?? "runnable")) return `${target}'s kind changed since the reviewed revision`;
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
const amends = events.filter((e) => e.action === "amend").map((e) => e.payload.result as { id?: number; previousRevision?: string; titleChanged?: boolean } | undefined);
|
|
237
|
+
const start = amends.findIndex((a) => a?.id === Number(target.slice(1)) && a.previousRevision === previousRevision);
|
|
238
|
+
if (start === -1) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not the revision an amend started from; review the current text`;
|
|
239
|
+
if (amends.slice(start).some((a) => a?.id === Number(target.slice(1)) && a.titleChanged)) return `${target}'s claim line changed since the reviewed revision; review the new claim`;
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function reattestReview(c: CollaborationCommand): EventDraft {
|
|
244
|
+
const { ctx, state, params: p, seq } = c;
|
|
245
|
+
const target = requireParam(p.target, "target", p.action);
|
|
246
|
+
if (!/^[FA]\d+$/.test(target)) throw new Error("horde: reattest targets an F# or A#");
|
|
247
|
+
const info = targetInfo(ctx, state, target);
|
|
248
|
+
if (info.owner === ctx.actor) throw new Error("horde: an independent review needs another member");
|
|
249
|
+
if (p.revision !== info.revision) throw new Error("horde: reattest must name the current revision from inspect");
|
|
250
|
+
const previous = state.reviews.findLast((r) => r.target === target && r.actor === ctx.actor && r.revision !== info.revision);
|
|
251
|
+
if (!previous) throw new Error(`horde: no earlier review of ${target} by ${ctx.actor} to carry forward; record a review instead`);
|
|
252
|
+
const blocker = reattestBlocker(ctx, state, target, previous.revision);
|
|
253
|
+
if (blocker) throw new Error(`horde: cannot reattest: ${blocker}`);
|
|
254
|
+
if (state.reviews.some((r) => r.target === target && r.actor === ctx.actor && r.revision === info.revision)) {
|
|
255
|
+
throw new Error(`horde: ${ctx.actor} already reviewed ${target}@${info.revision.slice(0, 12)}`);
|
|
256
|
+
}
|
|
257
|
+
const refs = [...evidenceRefs(state, p.refs)];
|
|
258
|
+
if (!refs.length && !p.evidenceText) throw new Error("horde: reattest needs evidence that the checked behaviour is unchanged: refs or evidenceText");
|
|
259
|
+
const stale = staleEvidence(state, refs, target, info.revision);
|
|
260
|
+
if (stale) throw new Error(`horde: reattest evidence must address the current revision: ${stale}`);
|
|
261
|
+
const text = requireText(p.text, "reattest");
|
|
262
|
+
const evidenceRecord = p.evidenceText === undefined ? undefined : {
|
|
263
|
+
id: `E${seq}`, actor: ctx.actor, target, revision: info.revision, relation: "extends" as const,
|
|
264
|
+
text: p.evidenceText, ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
|
|
265
|
+
};
|
|
266
|
+
if (evidenceRecord) refs.push(evidenceRecord.id);
|
|
267
|
+
const review = { ...previous, id: `V${seq}`, revision: info.revision, text, evidence: refs, reattests: previous.id };
|
|
268
|
+
return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review,
|
|
269
|
+
...completeAssignedReview(c, target, `V${seq}`, text, refs),
|
|
270
|
+
...(review.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: [previous.id, ...refs.filter((id) => id !== evidenceRecord?.id)] };
|
|
222
271
|
}
|
|
223
272
|
|
|
224
273
|
/** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
|
|
@@ -239,6 +288,7 @@ function decide(c: CollaborationCommand): EventDraft {
|
|
|
239
288
|
case "message": return newMessage(c);
|
|
240
289
|
case "attach": return attachEvidence(c);
|
|
241
290
|
case "review": return recordReview(c);
|
|
291
|
+
case "reattest": return reattestReview(c);
|
|
242
292
|
case "artifact": return publishArtifact(c);
|
|
243
293
|
case "resource-state": return resourceTransition(c);
|
|
244
294
|
default: return transition(c);
|
package/src/tool/params.ts
CHANGED
|
@@ -28,19 +28,20 @@ export const HordeParams = Type.Object({
|
|
|
28
28
|
"confirm-solved",
|
|
29
29
|
"contest-solved",
|
|
30
30
|
"concerns",
|
|
31
|
-
"verify", "inbox", "message", "ack", "respond", "resolve", "defer",
|
|
32
|
-
"attach", "review", "artifact", "resource-state", "collaboration", "inspect",
|
|
31
|
+
"verify", "amend", "inbox", "message", "ack", "respond", "resolve", "defer",
|
|
32
|
+
"attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect",
|
|
33
33
|
] as const,
|
|
34
34
|
{
|
|
35
35
|
description:
|
|
36
36
|
"list/findings/facts/claims/resources/concerns: read shared state; post: BOARD.md; " +
|
|
37
37
|
"idea/claim/done/drop/rule-out: checklist (rule-out is a terminal negative); " +
|
|
38
38
|
"finding: FINDINGS.md with a claimed idea, a concern slug, and optional importance/" +
|
|
39
|
-
"confidence/support; " +
|
|
39
|
+
"confidence/support; amend: rewrite the body prose of your own F#id at its current revision; " +
|
|
40
40
|
"verify: record your independent reproduction of finding F#id with the support level you reached; " +
|
|
41
41
|
"fact: FACTS.md; assert/confirm/contest/retract/supersede: change claim C#id state; " +
|
|
42
42
|
"resource: claim a shared dataset, harness, corpus, or sandbox (with cleanup) before building it; " +
|
|
43
|
-
"solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it"
|
|
43
|
+
"solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it; " +
|
|
44
|
+
"reattest: carry your earlier review of F#/A# forward to its current revision with evidence that the checked behaviour is unchanged",
|
|
44
45
|
},
|
|
45
46
|
),
|
|
46
47
|
detail: Type.Optional(StringEnum(["summary", "full"] as const, { description: "Collaboration reads default to summaries; full returns complete page bodies, inspected subjects or write receipts. inspect E#/V#/Q# always returns the full record." })),
|
|
@@ -57,7 +58,8 @@ export const HordeParams = Type.Object({
|
|
|
57
58
|
relation: Type.Optional(StringEnum(["supports", "contradicts", "extends"] as const)),
|
|
58
59
|
verdict: Type.Optional(StringEnum(["supports", "partially-supports", "refutes", "inconclusive"] as const)),
|
|
59
60
|
checked: Type.Optional(Type.String({ description: "Exact assertions checked and method; required for review." })),
|
|
60
|
-
unchecked: Type.Optional(Type.String({ description: "Assertions
|
|
61
|
+
unchecked: Type.Optional(Type.String({ description: "Assertions inside the target's stated claim or criteria that you did not check; empty only for full coverage of that scope. Disclosures outside the criteria go in limits." })),
|
|
62
|
+
limits: Type.Optional(Type.String({ description: "For review: limits outside the target's declared claim or criteria (one engine, no hardened deployment). Recorded on the acceptance as limits, never as blockers." })),
|
|
61
63
|
artifactKind: Type.Optional(StringEnum(["runnable", "document"] as const, { description: "Artifact classification, immutable after publication. Default runnable requires tested/reproduced review. document permits full-scope sourced/derived review of its stated criteria, not runtime acceptance." })),
|
|
62
64
|
artifactPath: Type.Optional(Type.String({ description: "File path relative to this run, under artifacts/, retained by content hash." })),
|
|
63
65
|
criteria: Type.Optional(Type.String({ description: "Artifact acceptance criteria, including positive/negative tests." })),
|
|
@@ -168,8 +170,8 @@ export type HordeToolParams = {
|
|
|
168
170
|
| "confirm-solved"
|
|
169
171
|
| "contest-solved"
|
|
170
172
|
| "concerns"
|
|
171
|
-
| "verify" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
|
|
172
|
-
| "attach" | "review" | "artifact" | "resource-state" | "collaboration" | "inspect";
|
|
173
|
+
| "verify" | "amend" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
|
|
174
|
+
| "attach" | "review" | "reattest" | "artifact" | "resource-state" | "collaboration" | "inspect";
|
|
173
175
|
detail?: "summary" | "full";
|
|
174
176
|
offset?: number;
|
|
175
177
|
limit?: number;
|
|
@@ -185,6 +187,7 @@ export type HordeToolParams = {
|
|
|
185
187
|
verdict?: "supports" | "partially-supports" | "refutes" | "inconclusive";
|
|
186
188
|
checked?: string;
|
|
187
189
|
unchecked?: string;
|
|
190
|
+
limits?: string;
|
|
188
191
|
artifactPath?: string;
|
|
189
192
|
artifactKind?: "runnable" | "document";
|
|
190
193
|
criteria?: string;
|
package/src/tool/register.ts
CHANGED
|
@@ -2,8 +2,9 @@ import * as path from "node:path";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { cleanStopRequestedFor, writeHeartbeat } from "../ledger/run-signals.js";
|
|
4
4
|
import { envNumber } from "../format.js";
|
|
5
|
+
import { parseSiblings } from "../member.js";
|
|
5
6
|
import { ReminderGate, goalReminder } from "../reminder.js";
|
|
6
|
-
import { runHordeAction } from "./actions.js";
|
|
7
|
+
import { isVerifierRole, runHordeAction } from "./actions.js";
|
|
7
8
|
import { pendingVerificationHint, solvedHint } from "./hints.js";
|
|
8
9
|
import { HordeParams } from "./params.js";
|
|
9
10
|
import { compactReceipt } from "./collaboration-view.js";
|
|
@@ -18,9 +19,12 @@ const REMIND_MS = envNumber("PI_HORDE_REMIND_MS", 600_000);
|
|
|
18
19
|
const MUTATING_ACTIONS = new Set([
|
|
19
20
|
"post", "idea", "claim", "done", "drop", "rule-out", "finding", "fact", "assert", "confirm",
|
|
20
21
|
"contest", "retract", "supersede", "resource", "solved", "confirm-solved", "contest-solved",
|
|
21
|
-
"verify", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state",
|
|
22
|
+
"verify", "amend", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state",
|
|
22
23
|
]);
|
|
23
24
|
|
|
25
|
+
/** After these, a verifier is shown what it still has to review. */
|
|
26
|
+
const VERIFIER_QUEUE_ACTIONS = new Set(["verify", "review", "reattest", "post", "findings"]);
|
|
27
|
+
|
|
24
28
|
export function registerMemberTool(pi: ExtensionAPI): void {
|
|
25
29
|
const hordeDir = process.env.PI_HORDE_DIR;
|
|
26
30
|
const member = process.env.PI_HORDE_NAME;
|
|
@@ -28,6 +32,7 @@ export function registerMemberTool(pi: ExtensionAPI): void {
|
|
|
28
32
|
throw new Error("agent-horde: PI_HORDE_DIR and PI_HORDE_NAME must be set for a horde member");
|
|
29
33
|
}
|
|
30
34
|
const attempt = process.env.PI_HORDE_ATTEMPT ?? `pid-${process.pid}`;
|
|
35
|
+
const siblings = parseSiblings(process.env.PI_HORDE_SIBLINGS);
|
|
31
36
|
const activeRun = process.env.PI_HORDE_RUN;
|
|
32
37
|
if (activeRun) registerInbox(pi, { hordeDir, runId: activeRun, actor: member, attempt });
|
|
33
38
|
// One member per process, so one gate is the per-member reminder counter.
|
|
@@ -60,13 +65,15 @@ export function registerMemberTool(pi: ExtensionAPI): void {
|
|
|
60
65
|
parameters: HordeParams,
|
|
61
66
|
async execute(toolCallId, params) {
|
|
62
67
|
const runId = process.env.PI_HORDE_RUN;
|
|
63
|
-
const
|
|
68
|
+
const role = process.env.PI_HORDE_ROLE;
|
|
69
|
+
const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId, role, siblings });
|
|
64
70
|
const runDir = runId ? path.join(hordeDir, "runs", runId) : undefined;
|
|
65
71
|
if (runDir && MUTATING_ACTIONS.has(params.action)) touchHeartbeat(runDir, member);
|
|
66
72
|
let text = compactReceipt(result.text, params) + solvedHint(hordeDir, runId, member, params.action);
|
|
67
|
-
// A verifier's coverage claim is only a snapshot. Append the live pending queue
|
|
68
|
-
//
|
|
69
|
-
|
|
73
|
+
// A verifier's coverage claim is only a snapshot. Append the live pending queue after every
|
|
74
|
+
// review and every board update, so the unreviewed F# are named before wind-down and a
|
|
75
|
+
// finding filed later cannot read as already checked.
|
|
76
|
+
if (runId && VERIFIER_QUEUE_ACTIONS.has(params.action) && isVerifierRole(role)) {
|
|
70
77
|
text += pendingVerificationHint(hordeDir, runId, member);
|
|
71
78
|
}
|
|
72
79
|
const winding = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));
|
package/src/widget.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { formatElapsed } from "./format.js";
|
|
3
|
+
import { formatElapsed, formatTokens } from "./format.js";
|
|
4
4
|
import type { MemberState } from "./member.js";
|
|
5
|
-
import { memberActivity, statusCounts } from "./prompt.js";
|
|
5
|
+
import { memberActivity, statusCounts, totalUsage } from "./prompt.js";
|
|
6
6
|
|
|
7
7
|
interface WidgetRun {
|
|
8
8
|
id: string;
|
|
9
|
+
horde: number;
|
|
9
10
|
members: MemberState[];
|
|
10
11
|
startedAt: number;
|
|
11
12
|
cleanStopUntil?: number | undefined;
|
|
12
13
|
dashboardUrl?: string | undefined;
|
|
13
14
|
/** Steps still running after members finished, e.g. ["deliverable", "post-mortem"]. */
|
|
14
15
|
finalizing?: string[] | undefined;
|
|
16
|
+
reported?: boolean | undefined;
|
|
17
|
+
finalized?: boolean | undefined;
|
|
18
|
+
outcome?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface WidgetSwarm {
|
|
22
|
+
id: string;
|
|
23
|
+
runs: WidgetRun[];
|
|
24
|
+
startedAt: number;
|
|
25
|
+
dashboardUrl?: string | undefined;
|
|
26
|
+
/** The swarm step still running after every horde finalized, e.g. ["merge"]. */
|
|
27
|
+
finalizing?: string[] | undefined;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -65,8 +78,8 @@ function memberLine(m: MemberState, now: number, frame: number, theme: Theme): s
|
|
|
65
78
|
);
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
/** The widget for one horde: a header, then a line per member (a glyph strip past eight). */
|
|
82
|
+
function runLines(run: WidgetRun, now: number, frame: number, theme: Theme): string[] {
|
|
70
83
|
const lines = [headerLine(run, now, theme)];
|
|
71
84
|
if (run.members.length > COLLAPSE_ABOVE) {
|
|
72
85
|
lines.push(` ${run.members.map((m) => statusGlyph(m, frame, theme)).join(" ")}`);
|
|
@@ -77,22 +90,81 @@ export function widgetLines(run: WidgetRun, now: number, width: number, theme: T
|
|
|
77
90
|
} else {
|
|
78
91
|
for (const m of run.members) lines.push(memberLine(m, now, frame, theme));
|
|
79
92
|
}
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What one horde of a swarm is doing, in a word. */
|
|
97
|
+
function hordeActivity(run: WidgetRun, c: ReturnType<typeof statusCounts>): string {
|
|
98
|
+
if (run.finalized) return run.outcome ?? "finished";
|
|
99
|
+
if (run.finalizing && run.finalizing.length > 0) return `synthesizing ${run.finalizing.join(", ")}`;
|
|
100
|
+
if (run.reported) return "finalizing";
|
|
101
|
+
if (run.cleanStopUntil !== undefined) return "stopping";
|
|
102
|
+
return c.running + c.starting > 0 ? "running" : "finishing";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One line per horde: `h2 11/12 running 1 done t120 45.2k tok $1.20 running`. */
|
|
106
|
+
export function hordeLine(run: WidgetRun, now: number, frame: number, theme: Theme): string {
|
|
107
|
+
const c = statusCounts(run.members);
|
|
108
|
+
const usage = totalUsage(run.members);
|
|
109
|
+
const live = run.members.find((m) => m.status === "running");
|
|
110
|
+
const glyph = run.finalized
|
|
111
|
+
? theme.fg("success", "✓")
|
|
112
|
+
: live
|
|
113
|
+
? theme.fg("accent", SPINNER[frame % SPINNER.length] ?? "⠋")
|
|
114
|
+
: theme.fg("dim", "·");
|
|
115
|
+
const parts = [
|
|
116
|
+
`${c.running + c.starting}/${run.members.length} running`,
|
|
117
|
+
`${c.done} done`,
|
|
118
|
+
];
|
|
119
|
+
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
120
|
+
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
121
|
+
parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok $${usage.cost.toFixed(2)}`));
|
|
122
|
+
parts.push(theme.fg("muted", hordeActivity(run, c)));
|
|
123
|
+
return ` ${glyph} ${`h${run.horde}`.padEnd(3)} ${parts.join(" ")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function swarmHeader(swarm: WidgetSwarm, now: number, theme: Theme): string {
|
|
127
|
+
const members = swarm.runs.flatMap((run) => run.members);
|
|
128
|
+
const c = statusCounts(members);
|
|
129
|
+
const parts = [
|
|
130
|
+
theme.bold(theme.fg("accent", `swarm ${swarm.id}`)),
|
|
131
|
+
`${swarm.runs.length} hordes`,
|
|
132
|
+
`${c.running + c.starting} running`,
|
|
133
|
+
`${c.done} done`,
|
|
134
|
+
];
|
|
135
|
+
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
136
|
+
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
137
|
+
parts.push(formatElapsed(now - swarm.startedAt));
|
|
138
|
+
if (swarm.finalizing && swarm.finalizing.length > 0) {
|
|
139
|
+
parts.push(theme.fg("warning", `synthesizing ${swarm.finalizing.join(", ")}`));
|
|
140
|
+
}
|
|
141
|
+
if (swarm.dashboardUrl) parts.push(`${swarm.dashboardUrl}swarms/${swarm.id}`);
|
|
142
|
+
return `${parts.join(" ")} ${theme.fg("dim", "/agent-horde status | stop-hard | stop-clean [hN]")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A swarm of one shows its run as before; a larger swarm shows one line per horde. */
|
|
146
|
+
export function widgetLines(swarm: WidgetSwarm, now: number, width: number, theme: Theme): string[] {
|
|
147
|
+
const frame = Math.floor(now / 100) % SPINNER.length;
|
|
148
|
+
const single = swarm.runs.length === 1 ? swarm.runs[0] : undefined;
|
|
149
|
+
const lines = single
|
|
150
|
+
? runLines(single, now, frame, theme)
|
|
151
|
+
: [swarmHeader(swarm, now, theme), ...swarm.runs.map((run) => hordeLine(run, now, frame, theme))];
|
|
80
152
|
return lines.map((line) => truncateToWidth(line, width));
|
|
81
153
|
}
|
|
82
154
|
|
|
83
155
|
export class HordeWidget {
|
|
84
|
-
private readonly
|
|
156
|
+
private readonly swarm: WidgetSwarm;
|
|
85
157
|
private readonly theme: Theme;
|
|
86
158
|
private readonly now: () => number;
|
|
87
159
|
|
|
88
|
-
constructor(
|
|
89
|
-
this.
|
|
160
|
+
constructor(swarm: WidgetSwarm, theme: Theme, now: () => number = Date.now) {
|
|
161
|
+
this.swarm = swarm;
|
|
90
162
|
this.theme = theme;
|
|
91
163
|
this.now = now;
|
|
92
164
|
}
|
|
93
165
|
|
|
94
166
|
render(width: number): string[] {
|
|
95
|
-
return widgetLines(this.
|
|
167
|
+
return widgetLines(this.swarm, this.now(), width, this.theme);
|
|
96
168
|
}
|
|
97
169
|
|
|
98
170
|
invalidate(): void {}
|