harnery 0.17.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.d.ts.map +1 -1
- package/dist/commands/supervisor.js +76 -4
- package/dist/core/supervisor/index.d.ts +3 -3
- 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 +222 -3
- package/dist/core/supervisor/plan-types.d.ts +60 -3
- 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 +804 -56
- package/dist/core/supervisor/read.d.ts +3 -3
- package/dist/core/supervisor/read.d.ts.map +1 -1
- package/dist/core/supervisor/read.js +2 -2
- package/dist/core/supervisor/runner.d.ts.map +1 -1
- package/dist/core/supervisor/runner.js +7 -2
- package/dist/core/supervisor/service.js +3 -0
- package/dist/core/supervisor/state.d.ts +17 -2
- package/dist/core/supervisor/state.d.ts.map +1 -1
- package/dist/core/supervisor/state.js +211 -33
- 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 +92 -5
- package/src/core/supervisor/index.ts +17 -1
- package/src/core/supervisor/plan-read.ts +292 -2
- package/src/core/supervisor/plan-types.ts +69 -1
- package/src/core/supervisor/planning.ts +1012 -51
- package/src/core/supervisor/read.ts +16 -1
- package/src/core/supervisor/runner.ts +9 -2
- package/src/core/supervisor/service.ts +3 -0
- package/src/core/supervisor/state.ts +272 -31
- 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,19 +13,33 @@ 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,
|
|
19
|
+
readWorkflowProof,
|
|
17
20
|
runWorkflow,
|
|
18
21
|
WorkflowParkedError,
|
|
22
|
+
type WorkflowProof,
|
|
19
23
|
workflowScriptDigest,
|
|
20
24
|
} from "../workflow/index.ts";
|
|
21
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
readSupervisorPlan,
|
|
27
|
+
readSupervisorPlanReviewReceipt,
|
|
28
|
+
readSupervisorPlans,
|
|
29
|
+
} from "./plan-read.ts";
|
|
22
30
|
import {
|
|
23
31
|
SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
24
32
|
type SupervisorPlanEvent,
|
|
33
|
+
type SupervisorPlanMilestone,
|
|
25
34
|
type SupervisorPlanOutcome,
|
|
26
35
|
type SupervisorPlanProposal,
|
|
27
36
|
type SupervisorPlanRequest,
|
|
37
|
+
type SupervisorPlanReviewFinding,
|
|
38
|
+
type SupervisorPlanReviewReceipt,
|
|
39
|
+
type SupervisorPlanReviewReviewer,
|
|
40
|
+
type SupervisorPlanReviewRound,
|
|
28
41
|
type SupervisorPlanWorkSpec,
|
|
42
|
+
type SupervisorReplanningPolicy,
|
|
29
43
|
supervisorGraphFingerprint,
|
|
30
44
|
} from "./plan-types.ts";
|
|
31
45
|
import type { SupervisorRecord } from "./state.ts";
|
|
@@ -60,43 +74,136 @@ export async function runSupervisorPlanner(
|
|
|
60
74
|
generation: input.record.projection.plan_generation,
|
|
61
75
|
work: input.record.work,
|
|
62
76
|
});
|
|
77
|
+
const trigger = planTrigger(input.record);
|
|
63
78
|
const resumable =
|
|
64
79
|
history.latest?.status === "resumable" &&
|
|
65
80
|
history.latest.request.trigger_fingerprint === triggerFingerprint &&
|
|
66
81
|
history.latest.request.prior_root_work_id === input.record.projection.root_work_id
|
|
67
82
|
? history.latest
|
|
68
83
|
: undefined;
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
);
|
|
72
103
|
const scriptPath = planScriptPath(coordRoot, input.record.intent.id, request.id);
|
|
73
104
|
if (resumable) {
|
|
74
105
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
75
106
|
event: "plan.resumed",
|
|
76
107
|
actor: input.actor,
|
|
77
|
-
reason: `resuming
|
|
108
|
+
reason: `resuming planning workflow after approval ${resumable.approval_id}`,
|
|
78
109
|
approval_id: resumable.approval_id,
|
|
79
110
|
});
|
|
80
111
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const report = await runWorkflow(scriptPath, {
|
|
84
|
-
...input.engine,
|
|
112
|
+
if (interrupted) {
|
|
113
|
+
const recovered = await recoverReviewedPlan({
|
|
85
114
|
coordRoot,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
onLog: input.onLog,
|
|
115
|
+
record: input.record,
|
|
116
|
+
request,
|
|
117
|
+
history,
|
|
118
|
+
trigger: request.trigger ?? "recovery",
|
|
119
|
+
policy,
|
|
120
|
+
actor: input.actor,
|
|
93
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;
|
|
94
154
|
const proposal = normalizeProposal(
|
|
95
155
|
request.id,
|
|
96
|
-
|
|
156
|
+
rawProposal,
|
|
97
157
|
input.record,
|
|
98
158
|
history.materialized_work_ids.length,
|
|
159
|
+
request.trigger ?? "recovery",
|
|
160
|
+
reviewInput ? persistedProposalTimestamp(reviewInput.candidate) : undefined,
|
|
99
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
|
+
}
|
|
100
207
|
writeExclusiveJson(
|
|
101
208
|
planProposalPath(coordRoot, input.record.intent.id, request.id),
|
|
102
209
|
proposal,
|
|
@@ -132,7 +239,7 @@ export async function runSupervisorPlanner(
|
|
|
132
239
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
133
240
|
event: "plan.awaiting_approval",
|
|
134
241
|
actor: input.actor,
|
|
135
|
-
reason: `
|
|
242
|
+
reason: `planning workflow parked for approval ${error.approvalId}`,
|
|
136
243
|
approval_id: error.approvalId,
|
|
137
244
|
});
|
|
138
245
|
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
@@ -146,6 +253,187 @@ export async function runSupervisorPlanner(
|
|
|
146
253
|
}
|
|
147
254
|
}
|
|
148
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
|
+
|
|
149
437
|
export function applySupervisorPlanProposal(input: {
|
|
150
438
|
coordRoot: string;
|
|
151
439
|
record: SupervisorRecord;
|
|
@@ -157,10 +445,30 @@ export function applySupervisorPlanProposal(input: {
|
|
|
157
445
|
const policy = input.record.intent.replanning;
|
|
158
446
|
if (!policy) throw new Error(`supervisor ${input.record.intent.id} does not allow replanning`);
|
|
159
447
|
const plan = readSupervisorPlan(coordRoot, input.record.intent.id, input.planId);
|
|
160
|
-
if (plan.status === "applied") return outcome(plan);
|
|
161
|
-
if (
|
|
448
|
+
if (plan.status === "applied" || plan.status === "completed") return outcome(plan);
|
|
449
|
+
if (
|
|
450
|
+
plan.status !== "proposed" ||
|
|
451
|
+
!plan.proposal ||
|
|
452
|
+
!["apply", "complete"].includes(plan.proposal.decision)
|
|
453
|
+
) {
|
|
162
454
|
throw new Error(`supervisor plan ${input.planId} cannot be applied from ${plan.status}`);
|
|
163
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
|
+
}
|
|
164
472
|
if (plan.request.prior_root_work_id !== input.record.projection.root_work_id) {
|
|
165
473
|
throw new Error(
|
|
166
474
|
`supervisor plan ${input.planId} targets stale root ${plan.request.prior_root_work_id}`,
|
|
@@ -176,7 +484,16 @@ export function applySupervisorPlanProposal(input: {
|
|
|
176
484
|
plan.proposal,
|
|
177
485
|
input.record,
|
|
178
486
|
history.materialized_work_ids.filter((workId) => !workId.startsWith(`${input.planId}-`)).length,
|
|
487
|
+
plan.request.trigger ?? "recovery",
|
|
179
488
|
);
|
|
489
|
+
if (proposal.decision === "complete") {
|
|
490
|
+
appendPlanEvent(coordRoot, input.record.intent.id, input.planId, {
|
|
491
|
+
event: "plan.completed",
|
|
492
|
+
actor: input.actor,
|
|
493
|
+
reason: input.reason ?? proposal.rationale,
|
|
494
|
+
});
|
|
495
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, input.planId));
|
|
496
|
+
}
|
|
180
497
|
const ids = new Map<string, string>();
|
|
181
498
|
const created: string[] = [];
|
|
182
499
|
for (const spec of proposal.work) {
|
|
@@ -268,6 +585,7 @@ function createPlanRequest(
|
|
|
268
585
|
record: SupervisorRecord,
|
|
269
586
|
sequence: number,
|
|
270
587
|
triggerFingerprint: string,
|
|
588
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>,
|
|
271
589
|
): SupervisorPlanRequest {
|
|
272
590
|
const policy = record.intent.replanning;
|
|
273
591
|
if (!policy) throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -280,36 +598,48 @@ function createPlanRequest(
|
|
|
280
598
|
id: planId,
|
|
281
599
|
goal_id: record.intent.id,
|
|
282
600
|
sequence,
|
|
601
|
+
trigger,
|
|
283
602
|
trigger_fingerprint: triggerFingerprint,
|
|
284
603
|
prior_root_work_id: record.projection.root_work_id,
|
|
285
604
|
workflow_run_id: `${planId}-workflow`,
|
|
286
605
|
created_at: new Date().toISOString(),
|
|
287
606
|
};
|
|
607
|
+
const script = plannerScript(coordRoot, record, planId, trigger);
|
|
288
608
|
const dir = planDir(coordRoot, record.intent.id, planId);
|
|
289
609
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
290
610
|
chmodSync(dir, 0o700);
|
|
291
611
|
writeExclusiveJson(join(dir, "request.json"), request, "supervisor plan request");
|
|
292
|
-
const script = plannerScript(record, planId);
|
|
293
612
|
writeExclusive(join(dir, "planner.mjs"), script, "supervisor planner script");
|
|
294
613
|
return request;
|
|
295
614
|
}
|
|
296
615
|
|
|
297
|
-
function plannerScript(
|
|
616
|
+
function plannerScript(
|
|
617
|
+
coordRoot: string,
|
|
618
|
+
record: SupervisorRecord,
|
|
619
|
+
planId: string,
|
|
620
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>,
|
|
621
|
+
): string {
|
|
298
622
|
const policy = record.intent.replanning;
|
|
299
623
|
if (!policy) throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
300
624
|
const prompt = [
|
|
301
|
-
"You are producing
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
625
|
+
"You are producing one bounded replacement plan or milestone decision for a durable supervised goal.",
|
|
626
|
+
record.intent.mission
|
|
627
|
+
? "Return decision=apply with the next immutable milestone graph, decision=complete only when mission acceptance is met, or decision=attention when human judgment is required."
|
|
628
|
+
: "Return decision=apply with a replacement immutable root graph, or decision=attention when human judgment is required.",
|
|
629
|
+
"Never claim mission completion unless the frozen mission acceptance is met. Never request a workflow outside the frozen template catalog.",
|
|
305
630
|
"Dependencies may name an active work ID or an earlier key in your proposed work array.",
|
|
306
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.",
|
|
307
633
|
`Plan id: ${planId}`,
|
|
634
|
+
`Planning trigger: ${trigger}`,
|
|
308
635
|
`Goal: ${record.intent.title}`,
|
|
636
|
+
`Mission: ${JSON.stringify(record.intent.mission ?? null)}`,
|
|
309
637
|
`Original root: ${record.intent.root_work_id}`,
|
|
310
638
|
`Active root: ${record.projection.root_work_id}`,
|
|
311
639
|
`Remaining work attempts: ${record.projection.attempts_remaining}`,
|
|
312
640
|
`Remaining replans: ${record.projection.replans_remaining}`,
|
|
641
|
+
`Completed milestones: ${record.projection.milestones_completed}`,
|
|
642
|
+
`Remaining milestones: ${record.projection.milestones_remaining}`,
|
|
313
643
|
`Latest planner outcome: ${JSON.stringify(
|
|
314
644
|
record.plans.at(-1)
|
|
315
645
|
? {
|
|
@@ -328,52 +658,574 @@ function plannerScript(record: SupervisorRecord, planId: string): string {
|
|
|
328
658
|
dependencies: work.intent.dependencies,
|
|
329
659
|
state: work.projection.state,
|
|
330
660
|
reason: work.projection.reason,
|
|
661
|
+
proof: work.projection.proof_path
|
|
662
|
+
? (() => {
|
|
663
|
+
const proof = readWorkflowProof(coordRoot, work.projection.latest_run_id!);
|
|
664
|
+
return {
|
|
665
|
+
run_id: proof.run.id,
|
|
666
|
+
status: proof.run.status,
|
|
667
|
+
acceptance: proof.acceptance.summary,
|
|
668
|
+
repository: {
|
|
669
|
+
head_changed: proof.repository.drift.head_changed,
|
|
670
|
+
dirty_paths_added: proof.repository.drift.dirty_paths_added,
|
|
671
|
+
incomplete: proof.repository.drift.incomplete,
|
|
672
|
+
},
|
|
673
|
+
unknowns: proof.unknowns.map((unknown) => unknown.code),
|
|
674
|
+
};
|
|
675
|
+
})()
|
|
676
|
+
: null,
|
|
331
677
|
})),
|
|
332
678
|
)}`,
|
|
333
679
|
].join("\n\n");
|
|
334
|
-
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 = {
|
|
335
712
|
type: "object",
|
|
336
713
|
properties: {
|
|
337
|
-
|
|
714
|
+
verdict: { type: "string", enum: ["approve", "revise", "attention"] },
|
|
338
715
|
rationale: { type: "string" },
|
|
339
|
-
|
|
340
|
-
work: {
|
|
716
|
+
findings: {
|
|
341
717
|
type: "array",
|
|
342
718
|
items: {
|
|
343
719
|
type: "object",
|
|
344
720
|
properties: {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
dependencies: { type: "array", items: { type: "string" } },
|
|
350
|
-
template: { type: "string" },
|
|
721
|
+
code: { type: "string" },
|
|
722
|
+
severity: { type: "string", enum: ["blocking", "advisory"] },
|
|
723
|
+
summary: { type: "string" },
|
|
724
|
+
recommendation: { type: "string" },
|
|
351
725
|
},
|
|
352
|
-
required: ["
|
|
726
|
+
required: ["code", "severity", "summary", "recommendation"],
|
|
353
727
|
},
|
|
354
728
|
},
|
|
355
729
|
},
|
|
356
|
-
required: ["
|
|
730
|
+
required: ["verdict", "rationale", "findings"],
|
|
357
731
|
};
|
|
358
732
|
return [
|
|
359
|
-
`export const meta = ${JSON.stringify({ name: `
|
|
733
|
+
`export const meta = ${JSON.stringify({ name: `review-${planId}` })};`,
|
|
360
734
|
"export default async ({ agent, stage }) => {",
|
|
361
|
-
|
|
362
|
-
`
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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 };",
|
|
367
794
|
"};",
|
|
368
795
|
"",
|
|
369
796
|
].join("\n");
|
|
370
797
|
}
|
|
371
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 {
|
|
1170
|
+
type: "object",
|
|
1171
|
+
properties: {
|
|
1172
|
+
decision: {
|
|
1173
|
+
type: "string",
|
|
1174
|
+
enum: record.intent.mission ? ["apply", "complete", "attention"] : ["apply", "attention"],
|
|
1175
|
+
},
|
|
1176
|
+
rationale: { type: "string", minLength: 1, maxLength: MAX_REASON },
|
|
1177
|
+
root: { type: "string", maxLength: 32 },
|
|
1178
|
+
work: {
|
|
1179
|
+
type: "array",
|
|
1180
|
+
maxItems: policy.max_work_items_per_plan,
|
|
1181
|
+
items: {
|
|
1182
|
+
type: "object",
|
|
1183
|
+
properties: {
|
|
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 },
|
|
1198
|
+
},
|
|
1199
|
+
required: ["key", "title", "objective", "acceptance", "dependencies", "template"],
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
milestone: {
|
|
1203
|
+
type: "object",
|
|
1204
|
+
properties: {
|
|
1205
|
+
sequence: { type: "number" },
|
|
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
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
required: ["sequence", "title", "objective", "acceptance"],
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
required: ["decision", "rationale", "root", "work"],
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
372
1222
|
function normalizeProposal(
|
|
373
1223
|
planId: string,
|
|
374
1224
|
raw: unknown,
|
|
375
1225
|
record: SupervisorRecord,
|
|
376
1226
|
previouslyApplied: number,
|
|
1227
|
+
trigger: NonNullable<SupervisorPlanRequest["trigger"]>,
|
|
1228
|
+
preservedProposedAt?: string,
|
|
377
1229
|
): SupervisorPlanProposal {
|
|
378
1230
|
const policy = record.intent.replanning;
|
|
379
1231
|
if (!policy) throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -381,12 +1233,18 @@ function normalizeProposal(
|
|
|
381
1233
|
throw new Error("planner result must be an object");
|
|
382
1234
|
}
|
|
383
1235
|
const value = raw as Record<string, unknown>;
|
|
384
|
-
const
|
|
1236
|
+
const proposedAt = preservedProposedAt ?? new Date().toISOString();
|
|
1237
|
+
const decision = enumValue(value.decision, ["apply", "complete", "attention"], "plan decision");
|
|
385
1238
|
const rationale = bounded(value.rationale, "plan rationale", MAX_REASON);
|
|
386
1239
|
const root = typeof value.root === "string" ? value.root.trim() : "";
|
|
387
1240
|
if (!Array.isArray(value.work)) throw new Error("plan work must be an array");
|
|
388
|
-
if (decision === "attention") {
|
|
389
|
-
if (root || value.work.length > 0
|
|
1241
|
+
if (decision === "attention" || decision === "complete") {
|
|
1242
|
+
if (root || value.work.length > 0 || value.milestone !== undefined) {
|
|
1243
|
+
throw new Error(`${decision} plan must not contain work or milestone data`);
|
|
1244
|
+
}
|
|
1245
|
+
if (decision === "complete" && (!record.intent.mission || trigger !== "milestone")) {
|
|
1246
|
+
throw new Error("mission completion may be proposed only at a milestone boundary");
|
|
1247
|
+
}
|
|
390
1248
|
return {
|
|
391
1249
|
schema_version: SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
392
1250
|
plan_id: planId,
|
|
@@ -394,9 +1252,20 @@ function normalizeProposal(
|
|
|
394
1252
|
rationale,
|
|
395
1253
|
root: "",
|
|
396
1254
|
work: [],
|
|
397
|
-
proposed_at:
|
|
1255
|
+
proposed_at: proposedAt,
|
|
398
1256
|
};
|
|
399
1257
|
}
|
|
1258
|
+
const milestone = record.intent.mission
|
|
1259
|
+
? normalizeMilestone(value.milestone, record.projection.milestones_completed + 1)
|
|
1260
|
+
: undefined;
|
|
1261
|
+
if (!record.intent.mission && value.milestone !== undefined) {
|
|
1262
|
+
throw new Error("non-mission replacement plan must not contain milestone data");
|
|
1263
|
+
}
|
|
1264
|
+
if (milestone && milestone.sequence > record.intent.mission!.max_milestones) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`milestone ${milestone.sequence} exceeds mission limit ${record.intent.mission!.max_milestones}`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
400
1269
|
if (value.work.length < 1 || value.work.length > policy.max_work_items_per_plan) {
|
|
401
1270
|
throw new Error(`plan work must contain 1 to ${policy.max_work_items_per_plan} items`);
|
|
402
1271
|
}
|
|
@@ -477,10 +1346,56 @@ function normalizeProposal(
|
|
|
477
1346
|
rationale,
|
|
478
1347
|
root,
|
|
479
1348
|
work,
|
|
480
|
-
|
|
1349
|
+
...(milestone ? { milestone } : {}),
|
|
1350
|
+
proposed_at: proposedAt,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
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
|
+
|
|
1365
|
+
function normalizeMilestone(raw: unknown, expectedSequence: number): SupervisorPlanMilestone {
|
|
1366
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1367
|
+
throw new Error("mission plan must contain a milestone object");
|
|
1368
|
+
}
|
|
1369
|
+
const value = raw as Record<string, unknown>;
|
|
1370
|
+
if (value.sequence !== expectedSequence) {
|
|
1371
|
+
throw new Error(`milestone sequence must be ${expectedSequence}`);
|
|
1372
|
+
}
|
|
1373
|
+
if (!Array.isArray(value.acceptance) || value.acceptance.length < 1) {
|
|
1374
|
+
throw new Error("milestone acceptance must be a non-empty array");
|
|
1375
|
+
}
|
|
1376
|
+
if (value.acceptance.length > 50) throw new Error("milestone acceptance exceeds 50 criteria");
|
|
1377
|
+
return {
|
|
1378
|
+
sequence: expectedSequence,
|
|
1379
|
+
title: bounded(value.title, "milestone title", 200),
|
|
1380
|
+
objective: bounded(value.objective, "milestone objective", 4_000),
|
|
1381
|
+
acceptance: value.acceptance.map((criterion, index) =>
|
|
1382
|
+
bounded(criterion, `milestone acceptance[${index}]`, 500),
|
|
1383
|
+
),
|
|
481
1384
|
};
|
|
482
1385
|
}
|
|
483
1386
|
|
|
1387
|
+
function planTrigger(record: SupervisorRecord): NonNullable<SupervisorPlanRequest["trigger"]> {
|
|
1388
|
+
if (
|
|
1389
|
+
record.intent.mission &&
|
|
1390
|
+
record.projection.plan_generation === 0 &&
|
|
1391
|
+
record.work.length === 0
|
|
1392
|
+
) {
|
|
1393
|
+
return "initial";
|
|
1394
|
+
}
|
|
1395
|
+
const root = record.work.find((work) => work.intent.id === record.projection.root_work_id);
|
|
1396
|
+
return record.intent.mission && root?.projection.state === "succeeded" ? "milestone" : "recovery";
|
|
1397
|
+
}
|
|
1398
|
+
|
|
484
1399
|
function appendPlanEvent(
|
|
485
1400
|
coordRootRaw: string,
|
|
486
1401
|
goalId: string,
|
|
@@ -508,6 +1423,18 @@ function appendPlanEvent(
|
|
|
508
1423
|
chmodSync(path, 0o600);
|
|
509
1424
|
}
|
|
510
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
|
+
|
|
511
1438
|
function outcome(plan: ReturnType<typeof readSupervisorPlan>): SupervisorPlanOutcome {
|
|
512
1439
|
return {
|
|
513
1440
|
plan_id: plan.request.id,
|
|
@@ -560,6 +1487,33 @@ function planProposalPath(coordRoot: string, goalId: string, planId: string): st
|
|
|
560
1487
|
return join(planDir(coordRoot, goalId, planId), "proposal.json");
|
|
561
1488
|
}
|
|
562
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
|
+
|
|
563
1517
|
function bounded(value: unknown, field: string, max: number): string {
|
|
564
1518
|
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
565
1519
|
const normalized = value.trim();
|
|
@@ -568,6 +1522,13 @@ function bounded(value: unknown, field: string, max: number): string {
|
|
|
568
1522
|
return normalized;
|
|
569
1523
|
}
|
|
570
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
|
+
|
|
571
1532
|
function enumValue<T extends string>(value: unknown, values: readonly T[], field: string): T {
|
|
572
1533
|
if (typeof value !== "string" || !values.includes(value as T)) {
|
|
573
1534
|
throw new Error(`${field} must be one of ${values.join(", ")}`);
|