harnery 0.18.0 → 0.19.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 +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +187 -4
- package/dist/commands/supervisor.js +36 -1
- package/dist/core/supervisor/index.d.ts +2 -2
- package/dist/core/supervisor/index.d.ts.map +1 -1
- package/dist/core/supervisor/index.js +2 -2
- package/dist/core/supervisor/plan-read.d.ts +2 -1
- package/dist/core/supervisor/plan-read.d.ts.map +1 -1
- package/dist/core/supervisor/plan-read.js +175 -2
- package/dist/core/supervisor/plan-types.d.ts +48 -1
- package/dist/core/supervisor/plan-types.d.ts.map +1 -1
- package/dist/core/supervisor/plan-types.js +2 -0
- package/dist/core/supervisor/planning.d.ts.map +1 -1
- package/dist/core/supervisor/planning.js +705 -50
- package/dist/core/supervisor/read.d.ts +2 -2
- package/dist/core/supervisor/read.d.ts.map +1 -1
- package/dist/core/supervisor/read.js +2 -2
- package/dist/core/supervisor/state.d.ts.map +1 -1
- package/dist/core/supervisor/state.js +74 -6
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +24 -6
- package/dist/core/workflow/types.d.ts +6 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/validate.d.ts +3 -2
- package/dist/core/workflow/validate.d.ts.map +1 -1
- package/dist/core/workflow/validate.js +31 -3
- package/dist/lib/browser/client.d.ts +18 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +39 -0
- package/dist/lib/browser/geometry.d.ts +146 -0
- package/dist/lib/browser/geometry.d.ts.map +1 -0
- package/dist/lib/browser/geometry.js +525 -0
- package/dist/lib/browser/index.d.ts +2 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/target-size.d.ts +65 -0
- package/dist/lib/browser/target-size.d.ts.map +1 -0
- package/dist/lib/browser/target-size.js +108 -0
- package/package.json +7 -1
- package/src/commands/browse.ts +319 -0
- package/src/commands/supervisor.ts +45 -1
- package/src/core/supervisor/index.ts +14 -1
- package/src/core/supervisor/plan-read.ts +234 -1
- package/src/core/supervisor/plan-types.ts +55 -0
- package/src/core/supervisor/planning.ts +896 -57
- package/src/core/supervisor/read.ts +14 -1
- package/src/core/supervisor/state.ts +96 -3
- package/src/core/workflow/engine.ts +29 -6
- package/src/core/workflow/types.ts +6 -0
- package/src/core/workflow/validate.ts +34 -3
- package/src/lib/browser/client.ts +62 -0
- package/src/lib/browser/geometry.ts +695 -0
- package/src/lib/browser/index.ts +25 -0
- package/src/lib/browser/target-size.ts +180 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
appendFileSync,
|
|
4
4
|
chmodSync,
|
|
@@ -13,13 +13,20 @@ import {
|
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
14
14
|
import { createWorkItem, readWorkItem } from "../work/index.ts";
|
|
15
15
|
import {
|
|
16
|
+
digestResult,
|
|
16
17
|
type EngineOpts,
|
|
18
|
+
type ResultDigest,
|
|
17
19
|
readWorkflowProof,
|
|
18
20
|
runWorkflow,
|
|
19
21
|
WorkflowParkedError,
|
|
22
|
+
type WorkflowProof,
|
|
20
23
|
workflowScriptDigest,
|
|
21
24
|
} from "../workflow/index.ts";
|
|
22
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
readSupervisorPlan,
|
|
27
|
+
readSupervisorPlanReviewReceipt,
|
|
28
|
+
readSupervisorPlans,
|
|
29
|
+
} from "./plan-read.ts";
|
|
23
30
|
import {
|
|
24
31
|
SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
25
32
|
type SupervisorPlanEvent,
|
|
@@ -27,7 +34,12 @@ import {
|
|
|
27
34
|
type SupervisorPlanOutcome,
|
|
28
35
|
type SupervisorPlanProposal,
|
|
29
36
|
type SupervisorPlanRequest,
|
|
37
|
+
type SupervisorPlanReviewFinding,
|
|
38
|
+
type SupervisorPlanReviewReceipt,
|
|
39
|
+
type SupervisorPlanReviewReviewer,
|
|
40
|
+
type SupervisorPlanReviewRound,
|
|
30
41
|
type SupervisorPlanWorkSpec,
|
|
42
|
+
type SupervisorReplanningPolicy,
|
|
31
43
|
supervisorGraphFingerprint,
|
|
32
44
|
} from "./plan-types.ts";
|
|
33
45
|
import type { SupervisorRecord } from "./state.ts";
|
|
@@ -69,44 +81,129 @@ export async function runSupervisorPlanner(
|
|
|
69
81
|
history.latest.request.prior_root_work_id === input.record.projection.root_work_id
|
|
70
82
|
? history.latest
|
|
71
83
|
: undefined;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
const interrupted =
|
|
85
|
+
!resumable &&
|
|
86
|
+
history.latest?.status === "interrupted" &&
|
|
87
|
+
history.latest.request.trigger_fingerprint === triggerFingerprint &&
|
|
88
|
+
history.latest.request.prior_root_work_id === input.record.projection.root_work_id &&
|
|
89
|
+
policy.review &&
|
|
90
|
+
hasReviewRecoveryEvidence(coordRoot, input.record.intent.id, history.latest.request.id)
|
|
91
|
+
? history.latest
|
|
92
|
+
: undefined;
|
|
93
|
+
const request =
|
|
94
|
+
resumable || interrupted
|
|
95
|
+
? (resumable ?? interrupted)!.request
|
|
96
|
+
: createPlanRequest(
|
|
97
|
+
coordRoot,
|
|
98
|
+
input.record,
|
|
99
|
+
history.plans.length + 1,
|
|
100
|
+
triggerFingerprint,
|
|
101
|
+
trigger,
|
|
102
|
+
);
|
|
81
103
|
const scriptPath = planScriptPath(coordRoot, input.record.intent.id, request.id);
|
|
82
104
|
if (resumable) {
|
|
83
105
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
84
106
|
event: "plan.resumed",
|
|
85
107
|
actor: input.actor,
|
|
86
|
-
reason: `resuming
|
|
108
|
+
reason: `resuming planning workflow after approval ${resumable.approval_id}`,
|
|
87
109
|
approval_id: resumable.approval_id,
|
|
88
110
|
});
|
|
89
111
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const report = await runWorkflow(scriptPath, {
|
|
93
|
-
...input.engine,
|
|
112
|
+
if (interrupted) {
|
|
113
|
+
const recovered = await recoverReviewedPlan({
|
|
94
114
|
coordRoot,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
onLog: input.onLog,
|
|
115
|
+
record: input.record,
|
|
116
|
+
request,
|
|
117
|
+
history,
|
|
118
|
+
trigger: request.trigger ?? "recovery",
|
|
119
|
+
policy,
|
|
120
|
+
actor: input.actor,
|
|
102
121
|
});
|
|
122
|
+
if (recovered) return recovered;
|
|
123
|
+
}
|
|
124
|
+
let proposalWritten = false;
|
|
125
|
+
try {
|
|
126
|
+
const reviewRunId = reviewWorkflowRunId(request.id);
|
|
127
|
+
const resumingReview = Boolean(
|
|
128
|
+
resumable &&
|
|
129
|
+
policy.review &&
|
|
130
|
+
existsSync(reviewInputPath(coordRoot, input.record.intent.id, request.id)) &&
|
|
131
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewRunId, "run.json")),
|
|
132
|
+
);
|
|
133
|
+
const reviewInput = resumingReview
|
|
134
|
+
? readBoundedJson<{ candidate: unknown }>(
|
|
135
|
+
reviewInputPath(coordRoot, input.record.intent.id, request.id),
|
|
136
|
+
"supervisor plan review input",
|
|
137
|
+
)
|
|
138
|
+
: undefined;
|
|
139
|
+
const rawProposal = reviewInput
|
|
140
|
+
? reviewInput.candidate
|
|
141
|
+
: (
|
|
142
|
+
await runWorkflow(scriptPath, {
|
|
143
|
+
...input.engine,
|
|
144
|
+
coordRoot,
|
|
145
|
+
...(resumable
|
|
146
|
+
? { resumeRunId: request.workflow_run_id }
|
|
147
|
+
: { runId: request.workflow_run_id }),
|
|
148
|
+
specialists: input.record.intent.specialists,
|
|
149
|
+
maxAgents: 1,
|
|
150
|
+
concurrency: 1,
|
|
151
|
+
onLog: input.onLog,
|
|
152
|
+
})
|
|
153
|
+
).result;
|
|
103
154
|
const proposal = normalizeProposal(
|
|
104
155
|
request.id,
|
|
105
|
-
|
|
156
|
+
rawProposal,
|
|
106
157
|
input.record,
|
|
107
158
|
history.materialized_work_ids.length,
|
|
108
159
|
request.trigger ?? "recovery",
|
|
160
|
+
reviewInput ? persistedProposalTimestamp(reviewInput.candidate) : undefined,
|
|
109
161
|
);
|
|
162
|
+
if (policy.review) {
|
|
163
|
+
if (proposal.decision === "attention") {
|
|
164
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
165
|
+
event: "plan.attention",
|
|
166
|
+
actor: input.actor,
|
|
167
|
+
reason: proposal.rationale,
|
|
168
|
+
});
|
|
169
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
170
|
+
}
|
|
171
|
+
let receipt: SupervisorPlanReviewReceipt;
|
|
172
|
+
try {
|
|
173
|
+
receipt = await ensurePlanReview({
|
|
174
|
+
coordRoot,
|
|
175
|
+
record: input.record,
|
|
176
|
+
request,
|
|
177
|
+
proposal,
|
|
178
|
+
history,
|
|
179
|
+
trigger: request.trigger ?? "recovery",
|
|
180
|
+
policy,
|
|
181
|
+
engine: input.engine,
|
|
182
|
+
actor: input.actor,
|
|
183
|
+
onLog: input.onLog,
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof WorkflowParkedError) throw error;
|
|
187
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
188
|
+
event: "plan.attention",
|
|
189
|
+
actor: input.actor,
|
|
190
|
+
reason: bounded(
|
|
191
|
+
`plan review requires attention: ${(error as Error).message}`,
|
|
192
|
+
"plan review attention",
|
|
193
|
+
MAX_REASON,
|
|
194
|
+
),
|
|
195
|
+
});
|
|
196
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
197
|
+
}
|
|
198
|
+
return finalizeReviewedProposal({
|
|
199
|
+
coordRoot,
|
|
200
|
+
record: input.record,
|
|
201
|
+
request,
|
|
202
|
+
receipt,
|
|
203
|
+
policy,
|
|
204
|
+
actor: input.actor,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
110
207
|
writeExclusiveJson(
|
|
111
208
|
planProposalPath(coordRoot, input.record.intent.id, request.id),
|
|
112
209
|
proposal,
|
|
@@ -142,7 +239,7 @@ export async function runSupervisorPlanner(
|
|
|
142
239
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
143
240
|
event: "plan.awaiting_approval",
|
|
144
241
|
actor: input.actor,
|
|
145
|
-
reason: `
|
|
242
|
+
reason: `planning workflow parked for approval ${error.approvalId}`,
|
|
146
243
|
approval_id: error.approvalId,
|
|
147
244
|
});
|
|
148
245
|
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
@@ -156,6 +253,187 @@ export async function runSupervisorPlanner(
|
|
|
156
253
|
}
|
|
157
254
|
}
|
|
158
255
|
|
|
256
|
+
async function recoverReviewedPlan(input: {
|
|
257
|
+
coordRoot: string;
|
|
258
|
+
record: SupervisorRecord;
|
|
259
|
+
request: SupervisorPlanRequest;
|
|
260
|
+
history: ReturnType<typeof readSupervisorPlans>;
|
|
261
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>;
|
|
262
|
+
policy: SupervisorReplanningPolicy;
|
|
263
|
+
actor: string;
|
|
264
|
+
}): Promise<SupervisorPlanOutcome | undefined> {
|
|
265
|
+
const review = input.policy.review;
|
|
266
|
+
if (!review) return undefined;
|
|
267
|
+
const existing = readSupervisorPlanReviewReceipt(
|
|
268
|
+
input.coordRoot,
|
|
269
|
+
input.record.intent.id,
|
|
270
|
+
input.request.id,
|
|
271
|
+
);
|
|
272
|
+
const receipt =
|
|
273
|
+
existing ??
|
|
274
|
+
reconstructReviewReceiptFromProof({
|
|
275
|
+
coordRoot: input.coordRoot,
|
|
276
|
+
record: input.record,
|
|
277
|
+
request: input.request,
|
|
278
|
+
history: input.history,
|
|
279
|
+
trigger: input.trigger,
|
|
280
|
+
policy: input.policy,
|
|
281
|
+
});
|
|
282
|
+
if (!receipt) return undefined;
|
|
283
|
+
if (!existing) {
|
|
284
|
+
writeExclusiveJson(
|
|
285
|
+
reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id),
|
|
286
|
+
receipt,
|
|
287
|
+
"supervisor plan review receipt",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return finalizeReviewedProposal({
|
|
291
|
+
coordRoot: input.coordRoot,
|
|
292
|
+
record: input.record,
|
|
293
|
+
request: input.request,
|
|
294
|
+
receipt,
|
|
295
|
+
policy: input.policy,
|
|
296
|
+
actor: input.actor,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function ensurePlanReview(input: {
|
|
301
|
+
coordRoot: string;
|
|
302
|
+
record: SupervisorRecord;
|
|
303
|
+
request: SupervisorPlanRequest;
|
|
304
|
+
proposal: SupervisorPlanProposal;
|
|
305
|
+
history: ReturnType<typeof readSupervisorPlans>;
|
|
306
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>;
|
|
307
|
+
policy: SupervisorReplanningPolicy;
|
|
308
|
+
engine: Omit<EngineOpts, "coordRoot" | "runId" | "resumeRunId" | "specialists">;
|
|
309
|
+
actor: string;
|
|
310
|
+
onLog?: (line: string) => void;
|
|
311
|
+
}): Promise<SupervisorPlanReviewReceipt> {
|
|
312
|
+
const existing = readSupervisorPlanReviewReceipt(
|
|
313
|
+
input.coordRoot,
|
|
314
|
+
input.record.intent.id,
|
|
315
|
+
input.request.id,
|
|
316
|
+
);
|
|
317
|
+
if (existing) return existing;
|
|
318
|
+
const reconstructed = reconstructReviewReceiptFromProof(input);
|
|
319
|
+
if (reconstructed) {
|
|
320
|
+
writeExclusiveJson(
|
|
321
|
+
reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id),
|
|
322
|
+
reconstructed,
|
|
323
|
+
"supervisor plan review receipt",
|
|
324
|
+
);
|
|
325
|
+
return reconstructed;
|
|
326
|
+
}
|
|
327
|
+
const reviewScript = reviewScriptPath(input.coordRoot, input.record.intent.id, input.request.id);
|
|
328
|
+
writeExclusiveJson(
|
|
329
|
+
reviewInputPath(input.coordRoot, input.record.intent.id, input.request.id),
|
|
330
|
+
{ candidate: input.proposal },
|
|
331
|
+
"supervisor plan review input",
|
|
332
|
+
);
|
|
333
|
+
writeExclusive(
|
|
334
|
+
reviewScript,
|
|
335
|
+
plannerReviewScript(input.record, input.request.id, input.proposal, input.trigger),
|
|
336
|
+
"supervisor plan review script",
|
|
337
|
+
);
|
|
338
|
+
const review = input.policy.review!;
|
|
339
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
340
|
+
const runExists = existsSync(join(input.coordRoot, ".harnery", "workflows", runId, "run.json"));
|
|
341
|
+
const report = await runWorkflow(reviewScript, {
|
|
342
|
+
...input.engine,
|
|
343
|
+
coordRoot: input.coordRoot,
|
|
344
|
+
...(runExists ? { resumeRunId: runId } : { runId }),
|
|
345
|
+
specialists: input.record.intent.specialists,
|
|
346
|
+
maxAgents:
|
|
347
|
+
review.reviewer_specialists.length * (review.max_revision_rounds + 1) +
|
|
348
|
+
review.max_revision_rounds,
|
|
349
|
+
concurrency: input.record.intent.limits.agent_concurrency,
|
|
350
|
+
onLog: input.onLog,
|
|
351
|
+
});
|
|
352
|
+
const receipt = normalizeReviewReceipt({
|
|
353
|
+
raw: report.result,
|
|
354
|
+
initialCandidate: input.proposal,
|
|
355
|
+
record: input.record,
|
|
356
|
+
request: input.request,
|
|
357
|
+
history: input.history,
|
|
358
|
+
trigger: input.trigger,
|
|
359
|
+
policy: input.policy,
|
|
360
|
+
});
|
|
361
|
+
writeExclusiveJson(
|
|
362
|
+
reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id),
|
|
363
|
+
receipt,
|
|
364
|
+
"supervisor plan review receipt",
|
|
365
|
+
);
|
|
366
|
+
return receipt;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function finalizeReviewedProposal(input: {
|
|
370
|
+
coordRoot: string;
|
|
371
|
+
record: SupervisorRecord;
|
|
372
|
+
request: SupervisorPlanRequest;
|
|
373
|
+
receipt: SupervisorPlanReviewReceipt;
|
|
374
|
+
policy: SupervisorReplanningPolicy;
|
|
375
|
+
actor: string;
|
|
376
|
+
}): SupervisorPlanOutcome {
|
|
377
|
+
if (input.receipt.status !== "passed") {
|
|
378
|
+
const event = input.receipt.status === "failed" ? "plan.failed" : "plan.attention";
|
|
379
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, event, {
|
|
380
|
+
event,
|
|
381
|
+
actor: input.actor,
|
|
382
|
+
reason: reviewStatusReason(input.receipt),
|
|
383
|
+
});
|
|
384
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
385
|
+
}
|
|
386
|
+
// The review authority must become durable before the proposal can become
|
|
387
|
+
// visible. If the process stops after this event, the plan remains
|
|
388
|
+
// interrupted and proof-backed recovery can reconstruct the proposal. The
|
|
389
|
+
// opposite order can expose an unapprovable proposal with no recovery path.
|
|
390
|
+
appendPlanEventIfMissing(
|
|
391
|
+
input.coordRoot,
|
|
392
|
+
input.record.intent.id,
|
|
393
|
+
input.request.id,
|
|
394
|
+
"plan.reviewed",
|
|
395
|
+
{
|
|
396
|
+
event: "plan.reviewed",
|
|
397
|
+
actor: input.actor,
|
|
398
|
+
reason: `plan review passed after ${input.receipt.rounds.length} round(s)`,
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
writeExclusiveJson(
|
|
402
|
+
planProposalPath(input.coordRoot, input.record.intent.id, input.request.id),
|
|
403
|
+
input.receipt.final_candidate,
|
|
404
|
+
"supervisor plan proposal",
|
|
405
|
+
);
|
|
406
|
+
appendPlanEventIfMissing(
|
|
407
|
+
input.coordRoot,
|
|
408
|
+
input.record.intent.id,
|
|
409
|
+
input.request.id,
|
|
410
|
+
"plan.proposed",
|
|
411
|
+
{
|
|
412
|
+
event: "plan.proposed",
|
|
413
|
+
actor: input.actor,
|
|
414
|
+
reason: input.receipt.final_candidate.rationale,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
if (input.policy.auto_apply) {
|
|
418
|
+
return applySupervisorPlanProposal({
|
|
419
|
+
coordRoot: input.coordRoot,
|
|
420
|
+
record: input.record,
|
|
421
|
+
planId: input.request.id,
|
|
422
|
+
actor: input.actor,
|
|
423
|
+
reason: "proposal applied by frozen supervisor replanning policy",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function reviewStatusReason(receipt: SupervisorPlanReviewReceipt): string {
|
|
430
|
+
if (receipt.status === "revision_exhausted") {
|
|
431
|
+
return "plan review exhausted its bounded revision rounds";
|
|
432
|
+
}
|
|
433
|
+
if (receipt.status === "failed") return "plan review failed";
|
|
434
|
+
return "plan review requires attention";
|
|
435
|
+
}
|
|
436
|
+
|
|
159
437
|
export function applySupervisorPlanProposal(input: {
|
|
160
438
|
coordRoot: string;
|
|
161
439
|
record: SupervisorRecord;
|
|
@@ -175,6 +453,22 @@ export function applySupervisorPlanProposal(input: {
|
|
|
175
453
|
) {
|
|
176
454
|
throw new Error(`supervisor plan ${input.planId} cannot be applied from ${plan.status}`);
|
|
177
455
|
}
|
|
456
|
+
if (policy.review) {
|
|
457
|
+
const receipt = readSupervisorPlanReviewReceipt(
|
|
458
|
+
coordRoot,
|
|
459
|
+
input.record.intent.id,
|
|
460
|
+
input.planId,
|
|
461
|
+
);
|
|
462
|
+
if (receipt?.status !== "passed") {
|
|
463
|
+
throw new Error(`supervisor plan ${input.planId} has no passed review receipt`);
|
|
464
|
+
}
|
|
465
|
+
if (candidateDigest(plan.proposal) !== receipt.candidate_sha256) {
|
|
466
|
+
throw new Error(`supervisor plan ${input.planId} proposal does not match its passed review`);
|
|
467
|
+
}
|
|
468
|
+
if (!plan.events.some((event) => event.event === "plan.reviewed")) {
|
|
469
|
+
throw new Error(`supervisor plan ${input.planId} has no reviewed authority event`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
178
472
|
if (plan.request.prior_root_work_id !== input.record.projection.root_work_id) {
|
|
179
473
|
throw new Error(
|
|
180
474
|
`supervisor plan ${input.planId} targets stale root ${plan.request.prior_root_work_id}`,
|
|
@@ -335,6 +629,7 @@ function plannerScript(
|
|
|
335
629
|
"Never claim mission completion unless the frozen mission acceptance is met. Never request a workflow outside the frozen template catalog.",
|
|
336
630
|
"Dependencies may name an active work ID or an earlier key in your proposed work array.",
|
|
337
631
|
"Every proposed item must be reachable from root. The root must be a proposed key using a root-capable template.",
|
|
632
|
+
"Work keys are lowercase identifiers no longer than 32 characters. Keep titles within 200 characters, objectives within 4000 characters, and each acceptance criterion within 500 characters.",
|
|
338
633
|
`Plan id: ${planId}`,
|
|
339
634
|
`Planning trigger: ${trigger}`,
|
|
340
635
|
`Goal: ${record.intent.title}`,
|
|
@@ -382,26 +677,524 @@ function plannerScript(
|
|
|
382
677
|
})),
|
|
383
678
|
)}`,
|
|
384
679
|
].join("\n\n");
|
|
385
|
-
const schema =
|
|
680
|
+
const schema = planProposalSchema(record);
|
|
681
|
+
return [
|
|
682
|
+
`export const meta = ${JSON.stringify({ name: `replan-${planId}` })};`,
|
|
683
|
+
"export default async ({ agent, stage }) => {",
|
|
684
|
+
` stage(${JSON.stringify(
|
|
685
|
+
trigger === "initial"
|
|
686
|
+
? "Plan initial mission milestone"
|
|
687
|
+
: trigger === "milestone"
|
|
688
|
+
? "Reassess completed milestone"
|
|
689
|
+
: "Replan blocked goal",
|
|
690
|
+
)});`,
|
|
691
|
+
` return await agent(${JSON.stringify(prompt)}, ${JSON.stringify({
|
|
692
|
+
specialist: policy.planner_specialist,
|
|
693
|
+
schema,
|
|
694
|
+
label: "Produce bounded replacement plan",
|
|
695
|
+
})});`,
|
|
696
|
+
"};",
|
|
697
|
+
"",
|
|
698
|
+
].join("\n");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function plannerReviewScript(
|
|
702
|
+
record: SupervisorRecord,
|
|
703
|
+
planId: string,
|
|
704
|
+
initialCandidate: SupervisorPlanProposal,
|
|
705
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>,
|
|
706
|
+
): string {
|
|
707
|
+
const policy = record.intent.replanning;
|
|
708
|
+
const review = policy?.review;
|
|
709
|
+
if (!policy || !review) throw new Error(`supervisor ${record.intent.id} does not review plans`);
|
|
710
|
+
const proposalSchema = planProposalSchema(record);
|
|
711
|
+
const reviewerSchema = {
|
|
712
|
+
type: "object",
|
|
713
|
+
properties: {
|
|
714
|
+
verdict: { type: "string", enum: ["approve", "revise", "attention"] },
|
|
715
|
+
rationale: { type: "string" },
|
|
716
|
+
findings: {
|
|
717
|
+
type: "array",
|
|
718
|
+
items: {
|
|
719
|
+
type: "object",
|
|
720
|
+
properties: {
|
|
721
|
+
code: { type: "string" },
|
|
722
|
+
severity: { type: "string", enum: ["blocking", "advisory"] },
|
|
723
|
+
summary: { type: "string" },
|
|
724
|
+
recommendation: { type: "string" },
|
|
725
|
+
},
|
|
726
|
+
required: ["code", "severity", "summary", "recommendation"],
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
required: ["verdict", "rationale", "findings"],
|
|
731
|
+
};
|
|
732
|
+
return [
|
|
733
|
+
`export const meta = ${JSON.stringify({ name: `review-${planId}` })};`,
|
|
734
|
+
"export default async ({ agent, stage }) => {",
|
|
735
|
+
` const reviewers = ${JSON.stringify(review.reviewer_specialists)};`,
|
|
736
|
+
` const planner = ${JSON.stringify(policy.planner_specialist)};`,
|
|
737
|
+
` const maxRevisionRounds = ${JSON.stringify(review.max_revision_rounds)};`,
|
|
738
|
+
` const proposalSchema = ${JSON.stringify(proposalSchema)};`,
|
|
739
|
+
` const reviewerSchema = ${JSON.stringify(reviewerSchema)};`,
|
|
740
|
+
` const goalContext = ${JSON.stringify({
|
|
741
|
+
plan_id: planId,
|
|
742
|
+
trigger,
|
|
743
|
+
goal: record.intent.title,
|
|
744
|
+
mission: record.intent.mission ?? null,
|
|
745
|
+
original_root: record.intent.root_work_id,
|
|
746
|
+
active_root: record.projection.root_work_id,
|
|
747
|
+
templates: policy.templates,
|
|
748
|
+
})};`,
|
|
749
|
+
` let candidate = ${JSON.stringify(initialCandidate)};`,
|
|
750
|
+
" const rounds = [];",
|
|
751
|
+
" const aggregate = (items) => {",
|
|
752
|
+
" if (items.some((item) => item.verdict === 'attention')) return 'attention';",
|
|
753
|
+
" if (items.some((item) => item.verdict === 'revise')) return 'revise';",
|
|
754
|
+
" if (items.some((item) => item.findings.some((finding) => finding.severity === 'blocking'))) return 'revise';",
|
|
755
|
+
" return 'approved';",
|
|
756
|
+
" };",
|
|
757
|
+
" for (let round = 1; round <= maxRevisionRounds + 1; round++) {",
|
|
758
|
+
" stage('Review round ' + round);",
|
|
759
|
+
" const reviewersOut = await Promise.all(reviewers.map(async (specialist) => {",
|
|
760
|
+
" const prompt = [",
|
|
761
|
+
" 'Review this bounded supervisor plan candidate independently.',",
|
|
762
|
+
" 'Return approve only when the candidate is complete, scoped, and satisfies the goal context.',",
|
|
763
|
+
" 'Return revise for blocking defects. Return attention when human judgment is required.',",
|
|
764
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
765
|
+
" 'Candidate: ' + JSON.stringify(candidate)",
|
|
766
|
+
" ].join('\\n\\n');",
|
|
767
|
+
" const result = await agent(prompt, {",
|
|
768
|
+
" specialist,",
|
|
769
|
+
" schema: reviewerSchema,",
|
|
770
|
+
" label: 'Review plan candidate as ' + specialist",
|
|
771
|
+
" });",
|
|
772
|
+
" return { specialist, ...result };",
|
|
773
|
+
" }));",
|
|
774
|
+
" const roundRecord = { round, candidate, reviewers: reviewersOut, outcome: aggregate(reviewersOut) };",
|
|
775
|
+
" rounds.push(roundRecord);",
|
|
776
|
+
" if (roundRecord.outcome === 'approved' || roundRecord.outcome === 'attention') break;",
|
|
777
|
+
" if (round > maxRevisionRounds) break;",
|
|
778
|
+
" stage('Revise round ' + round);",
|
|
779
|
+
" const revisionPrompt = [",
|
|
780
|
+
" 'Revise this supervisor plan candidate. Return a complete replacement candidate, not a patch.',",
|
|
781
|
+
" 'Do not merge reviewer text mechanically; satisfy the blocking findings within the frozen goal and template constraints.',",
|
|
782
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
783
|
+
" 'Current candidate: ' + JSON.stringify(candidate),",
|
|
784
|
+
" 'Reviewer findings: ' + JSON.stringify(reviewersOut)",
|
|
785
|
+
" ].join('\\n\\n');",
|
|
786
|
+
" roundRecord.revision_candidate = await agent(revisionPrompt, {",
|
|
787
|
+
" specialist: planner,",
|
|
788
|
+
" schema: proposalSchema,",
|
|
789
|
+
" label: 'Revise bounded replacement plan'",
|
|
790
|
+
" });",
|
|
791
|
+
" candidate = roundRecord.revision_candidate;",
|
|
792
|
+
" }",
|
|
793
|
+
" return { rounds };",
|
|
794
|
+
"};",
|
|
795
|
+
"",
|
|
796
|
+
].join("\n");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function normalizeReviewReceipt(input: {
|
|
800
|
+
raw: unknown;
|
|
801
|
+
initialCandidate: SupervisorPlanProposal;
|
|
802
|
+
record: SupervisorRecord;
|
|
803
|
+
request: SupervisorPlanRequest;
|
|
804
|
+
history: ReturnType<typeof readSupervisorPlans>;
|
|
805
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>;
|
|
806
|
+
policy: SupervisorReplanningPolicy;
|
|
807
|
+
}): SupervisorPlanReviewReceipt {
|
|
808
|
+
const review = input.policy.review;
|
|
809
|
+
if (!review) throw new Error("missing review policy");
|
|
810
|
+
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
|
|
811
|
+
throw new Error("review result must be an object");
|
|
812
|
+
}
|
|
813
|
+
const raw = input.raw as Record<string, unknown>;
|
|
814
|
+
if (!Array.isArray(raw.rounds)) throw new Error("review result rounds must be an array");
|
|
815
|
+
if (raw.rounds.length < 1 || raw.rounds.length > review.max_revision_rounds + 1) {
|
|
816
|
+
throw new Error("review result has an invalid number of rounds");
|
|
817
|
+
}
|
|
818
|
+
const rounds: SupervisorPlanReviewRound[] = [];
|
|
819
|
+
let candidate = input.initialCandidate;
|
|
820
|
+
let status: SupervisorPlanReviewReceipt["status"] | undefined;
|
|
821
|
+
for (const [index, rawRound] of raw.rounds.entries()) {
|
|
822
|
+
if (!rawRound || typeof rawRound !== "object" || Array.isArray(rawRound)) {
|
|
823
|
+
throw new Error(`review round ${index + 1} must be an object`);
|
|
824
|
+
}
|
|
825
|
+
if (status) throw new Error("review result contains rounds after a terminal outcome");
|
|
826
|
+
const value = rawRound as Record<string, unknown>;
|
|
827
|
+
const roundNumber = positive(value.round, "review round", review.max_revision_rounds + 1);
|
|
828
|
+
if (roundNumber !== index + 1) throw new Error(`review round must be ${index + 1}`);
|
|
829
|
+
const reviewers = normalizeReviewers(value.reviewers, review.reviewer_specialists);
|
|
830
|
+
const outcome = aggregateReviewers(reviewers);
|
|
831
|
+
const round: SupervisorPlanReviewRound = {
|
|
832
|
+
round: roundNumber,
|
|
833
|
+
candidate_sha256: candidateDigest(candidate),
|
|
834
|
+
reviewers,
|
|
835
|
+
outcome,
|
|
836
|
+
};
|
|
837
|
+
if (outcome === "approved") {
|
|
838
|
+
status = "passed";
|
|
839
|
+
} else if (outcome === "attention") {
|
|
840
|
+
status = "attention";
|
|
841
|
+
} else if (roundNumber > review.max_revision_rounds) {
|
|
842
|
+
status = "revision_exhausted";
|
|
843
|
+
} else {
|
|
844
|
+
const revision = normalizeProposal(
|
|
845
|
+
input.request.id,
|
|
846
|
+
value.revision_candidate,
|
|
847
|
+
input.record,
|
|
848
|
+
input.history.materialized_work_ids.length,
|
|
849
|
+
input.trigger,
|
|
850
|
+
);
|
|
851
|
+
round.revision_workflow_run_id = reviewWorkflowRunId(input.request.id);
|
|
852
|
+
if (candidateDigest(revision) === candidateDigest(candidate)) {
|
|
853
|
+
status = "attention";
|
|
854
|
+
} else {
|
|
855
|
+
candidate = revision;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
rounds.push(round);
|
|
859
|
+
}
|
|
860
|
+
status ??= "attention";
|
|
861
|
+
return {
|
|
862
|
+
schema_version: SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
863
|
+
plan_id: input.request.id,
|
|
864
|
+
status,
|
|
865
|
+
candidate_sha256: candidateDigest(candidate),
|
|
866
|
+
final_candidate: candidate,
|
|
867
|
+
rounds,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function normalizeReviewers(
|
|
872
|
+
raw: unknown,
|
|
873
|
+
expectedSpecialists: readonly string[],
|
|
874
|
+
): SupervisorPlanReviewReviewer[] {
|
|
875
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length) {
|
|
876
|
+
throw new Error("reviewer result count does not match the frozen review policy");
|
|
877
|
+
}
|
|
878
|
+
return raw.map((value, index) => {
|
|
879
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
880
|
+
throw new Error(`reviewer result ${index + 1} must be an object`);
|
|
881
|
+
}
|
|
882
|
+
const item = value as Record<string, unknown>;
|
|
883
|
+
const specialist = bounded(item.specialist, `reviewer ${index + 1} specialist`, 100);
|
|
884
|
+
if (specialist !== expectedSpecialists[index]) {
|
|
885
|
+
throw new Error(`reviewer ${index + 1} does not match the frozen review order`);
|
|
886
|
+
}
|
|
887
|
+
const findings = normalizeFindings(item.findings, specialist);
|
|
888
|
+
return {
|
|
889
|
+
specialist,
|
|
890
|
+
verdict: enumValue(item.verdict, ["approve", "revise", "attention"], "review verdict"),
|
|
891
|
+
rationale: bounded(item.rationale, "review rationale", MAX_REASON),
|
|
892
|
+
findings,
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function normalizeFindings(raw: unknown, specialist: string): SupervisorPlanReviewFinding[] {
|
|
898
|
+
if (!Array.isArray(raw) || raw.length > 50) {
|
|
899
|
+
throw new Error(`review findings from ${specialist} must contain at most 50 items`);
|
|
900
|
+
}
|
|
901
|
+
return raw.map((value, index) => {
|
|
902
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
903
|
+
throw new Error(`review finding ${index + 1} from ${specialist} must be an object`);
|
|
904
|
+
}
|
|
905
|
+
const item = value as Record<string, unknown>;
|
|
906
|
+
return {
|
|
907
|
+
code: findingCode(item.code, specialist, index),
|
|
908
|
+
severity: enumValue(item.severity, ["blocking", "advisory"], "review finding severity"),
|
|
909
|
+
summary: bounded(item.summary, "review finding summary", 1_000),
|
|
910
|
+
recommendation: bounded(item.recommendation, "review finding recommendation", 1_000),
|
|
911
|
+
};
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function findingCode(value: unknown, specialist: string, index: number): string {
|
|
916
|
+
const code = bounded(value, `review finding ${index + 1} code from ${specialist}`, 100);
|
|
917
|
+
if (!/^[a-z][a-z0-9._-]*$/.test(code)) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`review finding ${index + 1} code from ${specialist} must be a stable lowercase identifier`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
return code;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function aggregateReviewers(
|
|
926
|
+
reviewers: readonly SupervisorPlanReviewReviewer[],
|
|
927
|
+
): SupervisorPlanReviewRound["outcome"] {
|
|
928
|
+
if (reviewers.length < 1) return "failed";
|
|
929
|
+
if (reviewers.some((reviewer) => reviewer.verdict === "attention")) return "attention";
|
|
930
|
+
if (
|
|
931
|
+
reviewers.some(
|
|
932
|
+
(reviewer) =>
|
|
933
|
+
reviewer.verdict === "revise" ||
|
|
934
|
+
reviewer.findings.some((finding) => finding.severity === "blocking"),
|
|
935
|
+
)
|
|
936
|
+
) {
|
|
937
|
+
return "revise";
|
|
938
|
+
}
|
|
939
|
+
return "approved";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function reconstructReviewReceiptFromProof(input: {
|
|
943
|
+
coordRoot: string;
|
|
944
|
+
record: SupervisorRecord;
|
|
945
|
+
request: SupervisorPlanRequest;
|
|
946
|
+
history: ReturnType<typeof readSupervisorPlans>;
|
|
947
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>;
|
|
948
|
+
policy: SupervisorReplanningPolicy;
|
|
949
|
+
}): SupervisorPlanReviewReceipt | undefined {
|
|
950
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
951
|
+
const proofPath = join(input.coordRoot, ".harnery", "workflows", runId, "proof.json");
|
|
952
|
+
if (!existsSync(proofPath)) return undefined;
|
|
953
|
+
const proof = readWorkflowProof(input.coordRoot, runId);
|
|
954
|
+
if (proof.run.status !== "succeeded") return undefined;
|
|
955
|
+
const raw = recoverReviewWorkflowResult(
|
|
956
|
+
input.coordRoot,
|
|
957
|
+
input.record.intent.id,
|
|
958
|
+
input.request.id,
|
|
959
|
+
proof,
|
|
960
|
+
input.policy.review!.reviewer_specialists,
|
|
961
|
+
);
|
|
962
|
+
assertResultDigest(raw.result, proof.run.result, runId);
|
|
963
|
+
return normalizeReviewReceipt({
|
|
964
|
+
raw: raw.result,
|
|
965
|
+
initialCandidate: raw.initialCandidate,
|
|
966
|
+
record: input.record,
|
|
967
|
+
request: input.request,
|
|
968
|
+
history: input.history,
|
|
969
|
+
trigger: input.trigger,
|
|
970
|
+
policy: input.policy,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function recoverReviewWorkflowResult(
|
|
975
|
+
coordRoot: string,
|
|
976
|
+
goalId: string,
|
|
977
|
+
planId: string,
|
|
978
|
+
proof: WorkflowProof,
|
|
979
|
+
expectedSpecialists: readonly string[],
|
|
980
|
+
): {
|
|
981
|
+
initialCandidate: SupervisorPlanProposal;
|
|
982
|
+
result: { rounds: unknown[] };
|
|
983
|
+
} {
|
|
984
|
+
const inputPath = reviewInputPath(coordRoot, goalId, planId);
|
|
985
|
+
const reviewInput = readBoundedJson<{ candidate: SupervisorPlanProposal }>(
|
|
986
|
+
inputPath,
|
|
987
|
+
"supervisor plan review input",
|
|
988
|
+
);
|
|
989
|
+
const journal = readVerifiedWorkflowJournal(coordRoot, reviewWorkflowRunId(planId), proof);
|
|
990
|
+
const starts = new Map<string, { specialist?: string }>();
|
|
991
|
+
const byRound = new Map<number, Record<string, unknown>>();
|
|
992
|
+
let currentCandidate: unknown = reviewInput.candidate;
|
|
993
|
+
for (const line of journal.split("\n")) {
|
|
994
|
+
if (!line.trim()) continue;
|
|
995
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
996
|
+
if (event.event === "agent.start" && typeof event.id === "string") {
|
|
997
|
+
starts.set(event.id, {
|
|
998
|
+
specialist: typeof event.specialist === "string" ? event.specialist : undefined,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
if (event.event !== "agent.end" || event.result_kind !== "json") continue;
|
|
1002
|
+
const round = workflowJournalRound(event.stage);
|
|
1003
|
+
if (!round) continue;
|
|
1004
|
+
let record = byRound.get(round.round);
|
|
1005
|
+
if (!record) {
|
|
1006
|
+
record = { round: round.round, candidate: currentCandidate, reviewers: [] };
|
|
1007
|
+
byRound.set(round.round, record);
|
|
1008
|
+
}
|
|
1009
|
+
if (round.kind === "review") {
|
|
1010
|
+
const specialist =
|
|
1011
|
+
typeof event.id === "string" ? starts.get(event.id)?.specialist : undefined;
|
|
1012
|
+
if (!event.result || typeof event.result !== "object" || Array.isArray(event.result)) {
|
|
1013
|
+
throw new Error("supervisor plan review journal contains an invalid reviewer result");
|
|
1014
|
+
}
|
|
1015
|
+
(record.reviewers as unknown[]).push({
|
|
1016
|
+
specialist,
|
|
1017
|
+
...(event.result as Record<string, unknown>),
|
|
1018
|
+
});
|
|
1019
|
+
} else {
|
|
1020
|
+
record.revision_candidate = event.result;
|
|
1021
|
+
currentCandidate = event.result;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const rounds = [...byRound.entries()]
|
|
1025
|
+
.sort(([left], [right]) => left - right)
|
|
1026
|
+
.map(([, value]) => {
|
|
1027
|
+
const reviewers = orderRecoveredReviewers(value.reviewers, expectedSpecialists);
|
|
1028
|
+
const round: Record<string, unknown> = {
|
|
1029
|
+
round: value.round,
|
|
1030
|
+
candidate: value.candidate,
|
|
1031
|
+
reviewers,
|
|
1032
|
+
outcome: aggregateRecoveredReviewers(reviewers),
|
|
1033
|
+
};
|
|
1034
|
+
if (value.revision_candidate !== undefined) {
|
|
1035
|
+
round.revision_candidate = value.revision_candidate;
|
|
1036
|
+
}
|
|
1037
|
+
return round;
|
|
1038
|
+
});
|
|
1039
|
+
return {
|
|
1040
|
+
initialCandidate: reviewInput.candidate,
|
|
1041
|
+
result: { rounds },
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function readVerifiedWorkflowJournal(
|
|
1046
|
+
coordRoot: string,
|
|
1047
|
+
runId: string,
|
|
1048
|
+
proof: WorkflowProof,
|
|
1049
|
+
): string {
|
|
1050
|
+
const journalPath = join(coordRoot, ".harnery", "workflows", runId, proof.integrity.journal.path);
|
|
1051
|
+
const journal = readFileSync(journalPath, "utf8");
|
|
1052
|
+
const bytes = Buffer.byteLength(journal);
|
|
1053
|
+
if (bytes <= 0 || bytes > MAX_RECORD_BYTES || bytes !== proof.integrity.journal.bytes) {
|
|
1054
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
1055
|
+
}
|
|
1056
|
+
const sha256 = createHash("sha256").update(journal).digest("hex");
|
|
1057
|
+
if (sha256 !== proof.integrity.journal.sha256) {
|
|
1058
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
1059
|
+
}
|
|
1060
|
+
return journal;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function orderRecoveredReviewers(raw: unknown, expectedSpecialists: readonly string[]): unknown[] {
|
|
1064
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length) return raw as unknown[];
|
|
1065
|
+
const expected = new Set(expectedSpecialists);
|
|
1066
|
+
const bySpecialist = new Map<string, unknown>();
|
|
1067
|
+
for (const value of raw) {
|
|
1068
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return raw;
|
|
1069
|
+
const specialist = (value as Record<string, unknown>).specialist;
|
|
1070
|
+
if (
|
|
1071
|
+
typeof specialist !== "string" ||
|
|
1072
|
+
!expected.has(specialist) ||
|
|
1073
|
+
bySpecialist.has(specialist)
|
|
1074
|
+
) {
|
|
1075
|
+
return raw;
|
|
1076
|
+
}
|
|
1077
|
+
bySpecialist.set(specialist, value);
|
|
1078
|
+
}
|
|
1079
|
+
return expectedSpecialists.map((specialist) => bySpecialist.get(specialist));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function aggregateRecoveredReviewers(reviewers: readonly unknown[]): string {
|
|
1083
|
+
if (
|
|
1084
|
+
reviewers.some(
|
|
1085
|
+
(value) =>
|
|
1086
|
+
value &&
|
|
1087
|
+
typeof value === "object" &&
|
|
1088
|
+
!Array.isArray(value) &&
|
|
1089
|
+
(value as Record<string, unknown>).verdict === "attention",
|
|
1090
|
+
)
|
|
1091
|
+
) {
|
|
1092
|
+
return "attention";
|
|
1093
|
+
}
|
|
1094
|
+
if (
|
|
1095
|
+
reviewers.some((value) => {
|
|
1096
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
|
|
1097
|
+
const reviewer = value as Record<string, unknown>;
|
|
1098
|
+
return (
|
|
1099
|
+
reviewer.verdict === "revise" ||
|
|
1100
|
+
(Array.isArray(reviewer.findings) &&
|
|
1101
|
+
reviewer.findings.some(
|
|
1102
|
+
(finding) =>
|
|
1103
|
+
finding &&
|
|
1104
|
+
typeof finding === "object" &&
|
|
1105
|
+
!Array.isArray(finding) &&
|
|
1106
|
+
(finding as Record<string, unknown>).severity === "blocking",
|
|
1107
|
+
))
|
|
1108
|
+
);
|
|
1109
|
+
})
|
|
1110
|
+
) {
|
|
1111
|
+
return "revise";
|
|
1112
|
+
}
|
|
1113
|
+
return "approved";
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function assertResultDigest(raw: unknown, expected: ResultDigest | undefined, runId: string): void {
|
|
1117
|
+
if (!expected) {
|
|
1118
|
+
throw new Error(`supervisor plan review proof ${runId} is missing its result digest`);
|
|
1119
|
+
}
|
|
1120
|
+
const actual = digestResult(raw, "json");
|
|
1121
|
+
if (
|
|
1122
|
+
actual.kind !== expected.kind ||
|
|
1123
|
+
actual.sha256 !== expected.sha256 ||
|
|
1124
|
+
actual.bytes !== expected.bytes
|
|
1125
|
+
) {
|
|
1126
|
+
throw new Error(`supervisor plan review result does not match proof digest`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function workflowJournalRound(
|
|
1131
|
+
stage: unknown,
|
|
1132
|
+
): { kind: "review" | "revision"; round: number } | undefined {
|
|
1133
|
+
if (typeof stage !== "string") return undefined;
|
|
1134
|
+
const review = /^Review round ([1-9][0-9]*)$/.exec(stage);
|
|
1135
|
+
if (review) return { kind: "review", round: Number(review[1]) };
|
|
1136
|
+
const revision = /^Revise round ([1-9][0-9]*)$/.exec(stage);
|
|
1137
|
+
if (revision) return { kind: "revision", round: Number(revision[1]) };
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function candidateDigest(candidate: SupervisorPlanProposal): string {
|
|
1142
|
+
return createHash("sha256")
|
|
1143
|
+
.update(JSON.stringify(canonicalCandidate(candidate)))
|
|
1144
|
+
.digest("hex");
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function canonicalCandidate(candidate: SupervisorPlanProposal): unknown {
|
|
1148
|
+
return {
|
|
1149
|
+
schema_version: candidate.schema_version,
|
|
1150
|
+
plan_id: candidate.plan_id,
|
|
1151
|
+
decision: candidate.decision,
|
|
1152
|
+
rationale: candidate.rationale,
|
|
1153
|
+
root: candidate.root,
|
|
1154
|
+
work: candidate.work,
|
|
1155
|
+
milestone: candidate.milestone,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function hasReviewRecoveryEvidence(coordRoot: string, goalId: string, planId: string): boolean {
|
|
1160
|
+
return (
|
|
1161
|
+
existsSync(reviewReceiptPath(coordRoot, goalId, planId)) ||
|
|
1162
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewWorkflowRunId(planId), "proof.json"))
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function planProposalSchema(record: SupervisorRecord): Record<string, unknown> {
|
|
1167
|
+
const policy = record.intent.replanning;
|
|
1168
|
+
if (!policy) throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
1169
|
+
return {
|
|
386
1170
|
type: "object",
|
|
387
1171
|
properties: {
|
|
388
1172
|
decision: {
|
|
389
1173
|
type: "string",
|
|
390
1174
|
enum: record.intent.mission ? ["apply", "complete", "attention"] : ["apply", "attention"],
|
|
391
1175
|
},
|
|
392
|
-
rationale: { type: "string" },
|
|
393
|
-
root: { type: "string" },
|
|
1176
|
+
rationale: { type: "string", minLength: 1, maxLength: MAX_REASON },
|
|
1177
|
+
root: { type: "string", maxLength: 32 },
|
|
394
1178
|
work: {
|
|
395
1179
|
type: "array",
|
|
1180
|
+
maxItems: policy.max_work_items_per_plan,
|
|
396
1181
|
items: {
|
|
397
1182
|
type: "object",
|
|
398
1183
|
properties: {
|
|
399
|
-
key: { type: "string" },
|
|
400
|
-
title: { type: "string" },
|
|
401
|
-
objective: { type: "string" },
|
|
402
|
-
acceptance: {
|
|
403
|
-
|
|
404
|
-
|
|
1184
|
+
key: { type: "string", pattern: "^[a-z][a-z0-9-]{0,31}$", maxLength: 32 },
|
|
1185
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
1186
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
1187
|
+
acceptance: {
|
|
1188
|
+
type: "array",
|
|
1189
|
+
maxItems: 50,
|
|
1190
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
1191
|
+
},
|
|
1192
|
+
dependencies: {
|
|
1193
|
+
type: "array",
|
|
1194
|
+
maxItems: 50,
|
|
1195
|
+
items: { type: "string", minLength: 1, maxLength: 100 },
|
|
1196
|
+
},
|
|
1197
|
+
template: { type: "string", enum: Object.keys(policy.templates), maxLength: 64 },
|
|
405
1198
|
},
|
|
406
1199
|
required: ["key", "title", "objective", "acceptance", "dependencies", "template"],
|
|
407
1200
|
},
|
|
@@ -410,33 +1203,20 @@ function plannerScript(
|
|
|
410
1203
|
type: "object",
|
|
411
1204
|
properties: {
|
|
412
1205
|
sequence: { type: "number" },
|
|
413
|
-
title: { type: "string" },
|
|
414
|
-
objective: { type: "string" },
|
|
415
|
-
acceptance: {
|
|
1206
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
1207
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
1208
|
+
acceptance: {
|
|
1209
|
+
type: "array",
|
|
1210
|
+
minItems: 1,
|
|
1211
|
+
maxItems: 50,
|
|
1212
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
1213
|
+
},
|
|
416
1214
|
},
|
|
417
1215
|
required: ["sequence", "title", "objective", "acceptance"],
|
|
418
1216
|
},
|
|
419
1217
|
},
|
|
420
1218
|
required: ["decision", "rationale", "root", "work"],
|
|
421
1219
|
};
|
|
422
|
-
return [
|
|
423
|
-
`export const meta = ${JSON.stringify({ name: `replan-${planId}` })};`,
|
|
424
|
-
"export default async ({ agent, stage }) => {",
|
|
425
|
-
` stage(${JSON.stringify(
|
|
426
|
-
trigger === "initial"
|
|
427
|
-
? "Plan initial mission milestone"
|
|
428
|
-
: trigger === "milestone"
|
|
429
|
-
? "Reassess completed milestone"
|
|
430
|
-
: "Replan blocked goal",
|
|
431
|
-
)});`,
|
|
432
|
-
` return await agent(${JSON.stringify(prompt)}, ${JSON.stringify({
|
|
433
|
-
specialist: policy.planner_specialist,
|
|
434
|
-
schema,
|
|
435
|
-
label: "Produce bounded replacement plan",
|
|
436
|
-
})});`,
|
|
437
|
-
"};",
|
|
438
|
-
"",
|
|
439
|
-
].join("\n");
|
|
440
1220
|
}
|
|
441
1221
|
|
|
442
1222
|
function normalizeProposal(
|
|
@@ -445,6 +1225,7 @@ function normalizeProposal(
|
|
|
445
1225
|
record: SupervisorRecord,
|
|
446
1226
|
previouslyApplied: number,
|
|
447
1227
|
trigger: NonNullable<SupervisorPlanRequest["trigger"]>,
|
|
1228
|
+
preservedProposedAt?: string,
|
|
448
1229
|
): SupervisorPlanProposal {
|
|
449
1230
|
const policy = record.intent.replanning;
|
|
450
1231
|
if (!policy) throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -452,6 +1233,7 @@ function normalizeProposal(
|
|
|
452
1233
|
throw new Error("planner result must be an object");
|
|
453
1234
|
}
|
|
454
1235
|
const value = raw as Record<string, unknown>;
|
|
1236
|
+
const proposedAt = preservedProposedAt ?? new Date().toISOString();
|
|
455
1237
|
const decision = enumValue(value.decision, ["apply", "complete", "attention"], "plan decision");
|
|
456
1238
|
const rationale = bounded(value.rationale, "plan rationale", MAX_REASON);
|
|
457
1239
|
const root = typeof value.root === "string" ? value.root.trim() : "";
|
|
@@ -470,7 +1252,7 @@ function normalizeProposal(
|
|
|
470
1252
|
rationale,
|
|
471
1253
|
root: "",
|
|
472
1254
|
work: [],
|
|
473
|
-
proposed_at:
|
|
1255
|
+
proposed_at: proposedAt,
|
|
474
1256
|
};
|
|
475
1257
|
}
|
|
476
1258
|
const milestone = record.intent.mission
|
|
@@ -565,10 +1347,21 @@ function normalizeProposal(
|
|
|
565
1347
|
root,
|
|
566
1348
|
work,
|
|
567
1349
|
...(milestone ? { milestone } : {}),
|
|
568
|
-
proposed_at:
|
|
1350
|
+
proposed_at: proposedAt,
|
|
569
1351
|
};
|
|
570
1352
|
}
|
|
571
1353
|
|
|
1354
|
+
function persistedProposalTimestamp(raw: unknown): string {
|
|
1355
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1356
|
+
throw new Error("persisted review candidate must be an object");
|
|
1357
|
+
}
|
|
1358
|
+
const value = (raw as Record<string, unknown>).proposed_at;
|
|
1359
|
+
if (typeof value !== "string" || value.length > 40 || !Number.isFinite(Date.parse(value))) {
|
|
1360
|
+
throw new Error("persisted review candidate has an invalid proposed_at timestamp");
|
|
1361
|
+
}
|
|
1362
|
+
return value;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
572
1365
|
function normalizeMilestone(raw: unknown, expectedSequence: number): SupervisorPlanMilestone {
|
|
573
1366
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
574
1367
|
throw new Error("mission plan must contain a milestone object");
|
|
@@ -630,6 +1423,18 @@ function appendPlanEvent(
|
|
|
630
1423
|
chmodSync(path, 0o600);
|
|
631
1424
|
}
|
|
632
1425
|
|
|
1426
|
+
function appendPlanEventIfMissing(
|
|
1427
|
+
coordRoot: string,
|
|
1428
|
+
goalId: string,
|
|
1429
|
+
planId: string,
|
|
1430
|
+
eventType: SupervisorPlanEvent["event"],
|
|
1431
|
+
input: Omit<SupervisorPlanEvent, "schema_version" | "plan_id" | "seq" | "ts">,
|
|
1432
|
+
): void {
|
|
1433
|
+
const plan = readSupervisorPlan(coordRoot, goalId, planId);
|
|
1434
|
+
if (plan.events.some((event) => event.event === eventType)) return;
|
|
1435
|
+
appendPlanEvent(coordRoot, goalId, planId, input);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
633
1438
|
function outcome(plan: ReturnType<typeof readSupervisorPlan>): SupervisorPlanOutcome {
|
|
634
1439
|
return {
|
|
635
1440
|
plan_id: plan.request.id,
|
|
@@ -682,6 +1487,33 @@ function planProposalPath(coordRoot: string, goalId: string, planId: string): st
|
|
|
682
1487
|
return join(planDir(coordRoot, goalId, planId), "proposal.json");
|
|
683
1488
|
}
|
|
684
1489
|
|
|
1490
|
+
function reviewScriptPath(coordRoot: string, goalId: string, planId: string): string {
|
|
1491
|
+
return join(planDir(coordRoot, goalId, planId), "review.mjs");
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function reviewInputPath(coordRoot: string, goalId: string, planId: string): string {
|
|
1495
|
+
return join(planDir(coordRoot, goalId, planId), "review-input.json");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function reviewReceiptPath(coordRoot: string, goalId: string, planId: string): string {
|
|
1499
|
+
return join(planDir(coordRoot, goalId, planId), "review.json");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function reviewWorkflowRunId(planId: string): string {
|
|
1503
|
+
return `${planId}-review`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function readBoundedJson<T>(path: string, label: string): T {
|
|
1507
|
+
if (!existsSync(path)) throw new Error(`${label} does not exist at ${path}`);
|
|
1508
|
+
const size = statSync(path).size;
|
|
1509
|
+
if (size <= 0 || size > MAX_RECORD_BYTES) throw new Error(`${label} has invalid size ${size}`);
|
|
1510
|
+
try {
|
|
1511
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
throw new Error(`cannot parse ${label} at ${path}: ${(error as Error).message}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
685
1517
|
function bounded(value: unknown, field: string, max: number): string {
|
|
686
1518
|
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
687
1519
|
const normalized = value.trim();
|
|
@@ -690,6 +1522,13 @@ function bounded(value: unknown, field: string, max: number): string {
|
|
|
690
1522
|
return normalized;
|
|
691
1523
|
}
|
|
692
1524
|
|
|
1525
|
+
function positive(value: unknown, field: string, max: number): number {
|
|
1526
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
1527
|
+
throw new Error(`${field} must be an integer from 1 to ${max}`);
|
|
1528
|
+
}
|
|
1529
|
+
return value;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
693
1532
|
function enumValue<T extends string>(value: unknown, values: readonly T[], field: string): T {
|
|
694
1533
|
if (typeof value !== "string" || !values.includes(value as T)) {
|
|
695
1534
|
throw new Error(`${field} must be one of ${values.join(", ")}`);
|