harnery 0.18.0 → 0.19.1
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 +762 -60
- 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 +9 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/validate.d.ts +5 -3
- package/dist/core/workflow/validate.d.ts.map +1 -1
- package/dist/core/workflow/validate.js +53 -4
- 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 +954 -65
- 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 +9 -0
- package/src/core/workflow/validate.ts +59 -4
- 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,15 +1,16 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { createWorkItem, readWorkItem } from "../work/index.js";
|
|
5
|
-
import { readWorkflowProof, runWorkflow, WorkflowParkedError, workflowScriptDigest, } from "../workflow/index.js";
|
|
6
|
-
import { readSupervisorPlan, readSupervisorPlans } from "./plan-read.js";
|
|
5
|
+
import { digestResult, readWorkflowProof, runWorkflow, WorkflowParkedError, workflowScriptDigest, } from "../workflow/index.js";
|
|
6
|
+
import { readSupervisorPlan, readSupervisorPlanReviewReceipt, readSupervisorPlans, } from "./plan-read.js";
|
|
7
7
|
import { SUPERVISOR_PLAN_SCHEMA_VERSION, supervisorGraphFingerprint, } from "./plan-types.js";
|
|
8
8
|
const PLAN_KEY = /^[a-z][a-z0-9-]{0,31}$/;
|
|
9
9
|
const TEMPLATE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
10
10
|
const MAX_RECORD_BYTES = 256 * 1024;
|
|
11
11
|
const MAX_EVENTS_BYTES = 512 * 1024;
|
|
12
12
|
const MAX_REASON = 2_000;
|
|
13
|
+
const PROPOSAL_ROOT_RULE = "proposal.root names one newly proposed work key using a root-capable template; it does not preserve or repeat active_root, which is context only";
|
|
13
14
|
export async function runSupervisorPlanner(input) {
|
|
14
15
|
const coordRoot = resolve(input.coordRoot);
|
|
15
16
|
const policy = input.record.intent.replanning;
|
|
@@ -27,32 +28,106 @@ export async function runSupervisorPlanner(input) {
|
|
|
27
28
|
history.latest.request.prior_root_work_id === input.record.projection.root_work_id
|
|
28
29
|
? history.latest
|
|
29
30
|
: undefined;
|
|
30
|
-
const
|
|
31
|
-
|
|
31
|
+
const interrupted = !resumable &&
|
|
32
|
+
history.latest?.status === "interrupted" &&
|
|
33
|
+
history.latest.request.trigger_fingerprint === triggerFingerprint &&
|
|
34
|
+
history.latest.request.prior_root_work_id === input.record.projection.root_work_id &&
|
|
35
|
+
policy.review &&
|
|
36
|
+
hasReviewRecoveryEvidence(coordRoot, input.record.intent.id, history.latest.request.id)
|
|
37
|
+
? history.latest
|
|
38
|
+
: undefined;
|
|
39
|
+
const request = resumable || interrupted
|
|
40
|
+
? (resumable ?? interrupted).request
|
|
32
41
|
: createPlanRequest(coordRoot, input.record, history.plans.length + 1, triggerFingerprint, trigger);
|
|
33
42
|
const scriptPath = planScriptPath(coordRoot, input.record.intent.id, request.id);
|
|
34
43
|
if (resumable) {
|
|
35
44
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
36
45
|
event: "plan.resumed",
|
|
37
46
|
actor: input.actor,
|
|
38
|
-
reason: `resuming
|
|
47
|
+
reason: `resuming planning workflow after approval ${resumable.approval_id}`,
|
|
39
48
|
approval_id: resumable.approval_id,
|
|
40
49
|
});
|
|
41
50
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const report = await runWorkflow(scriptPath, {
|
|
45
|
-
...input.engine,
|
|
51
|
+
if (interrupted) {
|
|
52
|
+
const recovered = await recoverReviewedPlan({
|
|
46
53
|
coordRoot,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
onLog: input.onLog,
|
|
54
|
+
record: input.record,
|
|
55
|
+
request,
|
|
56
|
+
history,
|
|
57
|
+
trigger: request.trigger ?? "recovery",
|
|
58
|
+
policy,
|
|
59
|
+
actor: input.actor,
|
|
54
60
|
});
|
|
55
|
-
|
|
61
|
+
if (recovered)
|
|
62
|
+
return recovered;
|
|
63
|
+
}
|
|
64
|
+
let proposalWritten = false;
|
|
65
|
+
try {
|
|
66
|
+
const reviewRunId = reviewWorkflowRunId(request.id);
|
|
67
|
+
const resumingReview = Boolean(resumable &&
|
|
68
|
+
policy.review &&
|
|
69
|
+
existsSync(reviewInputPath(coordRoot, input.record.intent.id, request.id)) &&
|
|
70
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewRunId, "run.json")));
|
|
71
|
+
const reviewInput = resumingReview
|
|
72
|
+
? readBoundedJson(reviewInputPath(coordRoot, input.record.intent.id, request.id), "supervisor plan review input")
|
|
73
|
+
: undefined;
|
|
74
|
+
const rawProposal = reviewInput
|
|
75
|
+
? reviewInput.candidate
|
|
76
|
+
: (await runWorkflow(scriptPath, {
|
|
77
|
+
...input.engine,
|
|
78
|
+
coordRoot,
|
|
79
|
+
...(resumable
|
|
80
|
+
? { resumeRunId: request.workflow_run_id }
|
|
81
|
+
: { runId: request.workflow_run_id }),
|
|
82
|
+
specialists: input.record.intent.specialists,
|
|
83
|
+
maxAgents: 1,
|
|
84
|
+
concurrency: 1,
|
|
85
|
+
onLog: input.onLog,
|
|
86
|
+
})).result;
|
|
87
|
+
const proposal = normalizeProposal(request.id, rawProposal, input.record, history.materialized_work_ids.length, request.trigger ?? "recovery", reviewInput ? persistedProposalTimestamp(reviewInput.candidate) : undefined);
|
|
88
|
+
if (policy.review) {
|
|
89
|
+
if (proposal.decision === "attention") {
|
|
90
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
91
|
+
event: "plan.attention",
|
|
92
|
+
actor: input.actor,
|
|
93
|
+
reason: proposal.rationale,
|
|
94
|
+
});
|
|
95
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
96
|
+
}
|
|
97
|
+
let receipt;
|
|
98
|
+
try {
|
|
99
|
+
receipt = await ensurePlanReview({
|
|
100
|
+
coordRoot,
|
|
101
|
+
record: input.record,
|
|
102
|
+
request,
|
|
103
|
+
proposal,
|
|
104
|
+
history,
|
|
105
|
+
trigger: request.trigger ?? "recovery",
|
|
106
|
+
policy,
|
|
107
|
+
engine: input.engine,
|
|
108
|
+
actor: input.actor,
|
|
109
|
+
onLog: input.onLog,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof WorkflowParkedError)
|
|
114
|
+
throw error;
|
|
115
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
116
|
+
event: "plan.attention",
|
|
117
|
+
actor: input.actor,
|
|
118
|
+
reason: bounded(`plan review requires attention: ${error.message}`, "plan review attention", MAX_REASON),
|
|
119
|
+
});
|
|
120
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
121
|
+
}
|
|
122
|
+
return finalizeReviewedProposal({
|
|
123
|
+
coordRoot,
|
|
124
|
+
record: input.record,
|
|
125
|
+
request,
|
|
126
|
+
receipt,
|
|
127
|
+
policy,
|
|
128
|
+
actor: input.actor,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
56
131
|
writeExclusiveJson(planProposalPath(coordRoot, input.record.intent.id, request.id), proposal, "supervisor plan proposal");
|
|
57
132
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
58
133
|
event: "plan.proposed",
|
|
@@ -86,7 +161,7 @@ export async function runSupervisorPlanner(input) {
|
|
|
86
161
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
87
162
|
event: "plan.awaiting_approval",
|
|
88
163
|
actor: input.actor,
|
|
89
|
-
reason: `
|
|
164
|
+
reason: `planning workflow parked for approval ${error.approvalId}`,
|
|
90
165
|
approval_id: error.approvalId,
|
|
91
166
|
});
|
|
92
167
|
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
@@ -99,6 +174,115 @@ export async function runSupervisorPlanner(input) {
|
|
|
99
174
|
throw error;
|
|
100
175
|
}
|
|
101
176
|
}
|
|
177
|
+
async function recoverReviewedPlan(input) {
|
|
178
|
+
const review = input.policy.review;
|
|
179
|
+
if (!review)
|
|
180
|
+
return undefined;
|
|
181
|
+
const existing = readSupervisorPlanReviewReceipt(input.coordRoot, input.record.intent.id, input.request.id);
|
|
182
|
+
const receipt = existing ??
|
|
183
|
+
reconstructReviewReceiptFromProof({
|
|
184
|
+
coordRoot: input.coordRoot,
|
|
185
|
+
record: input.record,
|
|
186
|
+
request: input.request,
|
|
187
|
+
history: input.history,
|
|
188
|
+
trigger: input.trigger,
|
|
189
|
+
policy: input.policy,
|
|
190
|
+
});
|
|
191
|
+
if (!receipt)
|
|
192
|
+
return undefined;
|
|
193
|
+
if (!existing) {
|
|
194
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), receipt, "supervisor plan review receipt");
|
|
195
|
+
}
|
|
196
|
+
return finalizeReviewedProposal({
|
|
197
|
+
coordRoot: input.coordRoot,
|
|
198
|
+
record: input.record,
|
|
199
|
+
request: input.request,
|
|
200
|
+
receipt,
|
|
201
|
+
policy: input.policy,
|
|
202
|
+
actor: input.actor,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function ensurePlanReview(input) {
|
|
206
|
+
const existing = readSupervisorPlanReviewReceipt(input.coordRoot, input.record.intent.id, input.request.id);
|
|
207
|
+
if (existing)
|
|
208
|
+
return existing;
|
|
209
|
+
const reconstructed = reconstructReviewReceiptFromProof(input);
|
|
210
|
+
if (reconstructed) {
|
|
211
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), reconstructed, "supervisor plan review receipt");
|
|
212
|
+
return reconstructed;
|
|
213
|
+
}
|
|
214
|
+
const reviewScript = reviewScriptPath(input.coordRoot, input.record.intent.id, input.request.id);
|
|
215
|
+
writeExclusiveJson(reviewInputPath(input.coordRoot, input.record.intent.id, input.request.id), { candidate: input.proposal }, "supervisor plan review input");
|
|
216
|
+
writeExclusive(reviewScript, plannerReviewScript(input.record, input.request.id, input.proposal, input.trigger), "supervisor plan review script");
|
|
217
|
+
const review = input.policy.review;
|
|
218
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
219
|
+
const runExists = existsSync(join(input.coordRoot, ".harnery", "workflows", runId, "run.json"));
|
|
220
|
+
const report = await runWorkflow(reviewScript, {
|
|
221
|
+
...input.engine,
|
|
222
|
+
coordRoot: input.coordRoot,
|
|
223
|
+
...(runExists ? { resumeRunId: runId } : { runId }),
|
|
224
|
+
specialists: input.record.intent.specialists,
|
|
225
|
+
maxAgents: review.reviewer_specialists.length * (review.max_revision_rounds + 1) +
|
|
226
|
+
review.max_revision_rounds,
|
|
227
|
+
concurrency: input.record.intent.limits.agent_concurrency,
|
|
228
|
+
onLog: input.onLog,
|
|
229
|
+
});
|
|
230
|
+
const receipt = normalizeReviewReceipt({
|
|
231
|
+
raw: report.result,
|
|
232
|
+
initialCandidate: input.proposal,
|
|
233
|
+
record: input.record,
|
|
234
|
+
request: input.request,
|
|
235
|
+
history: input.history,
|
|
236
|
+
trigger: input.trigger,
|
|
237
|
+
policy: input.policy,
|
|
238
|
+
});
|
|
239
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), receipt, "supervisor plan review receipt");
|
|
240
|
+
return receipt;
|
|
241
|
+
}
|
|
242
|
+
function finalizeReviewedProposal(input) {
|
|
243
|
+
if (input.receipt.status !== "passed") {
|
|
244
|
+
const event = input.receipt.status === "failed" ? "plan.failed" : "plan.attention";
|
|
245
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, event, {
|
|
246
|
+
event,
|
|
247
|
+
actor: input.actor,
|
|
248
|
+
reason: reviewStatusReason(input.receipt),
|
|
249
|
+
});
|
|
250
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
251
|
+
}
|
|
252
|
+
// The review authority must become durable before the proposal can become
|
|
253
|
+
// visible. If the process stops after this event, the plan remains
|
|
254
|
+
// interrupted and proof-backed recovery can reconstruct the proposal. The
|
|
255
|
+
// opposite order can expose an unapprovable proposal with no recovery path.
|
|
256
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, "plan.reviewed", {
|
|
257
|
+
event: "plan.reviewed",
|
|
258
|
+
actor: input.actor,
|
|
259
|
+
reason: `plan review passed after ${input.receipt.rounds.length} round(s)`,
|
|
260
|
+
});
|
|
261
|
+
writeExclusiveJson(planProposalPath(input.coordRoot, input.record.intent.id, input.request.id), input.receipt.final_candidate, "supervisor plan proposal");
|
|
262
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, "plan.proposed", {
|
|
263
|
+
event: "plan.proposed",
|
|
264
|
+
actor: input.actor,
|
|
265
|
+
reason: input.receipt.final_candidate.rationale,
|
|
266
|
+
});
|
|
267
|
+
if (input.policy.auto_apply) {
|
|
268
|
+
return applySupervisorPlanProposal({
|
|
269
|
+
coordRoot: input.coordRoot,
|
|
270
|
+
record: input.record,
|
|
271
|
+
planId: input.request.id,
|
|
272
|
+
actor: input.actor,
|
|
273
|
+
reason: "proposal applied by frozen supervisor replanning policy",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
277
|
+
}
|
|
278
|
+
function reviewStatusReason(receipt) {
|
|
279
|
+
if (receipt.status === "revision_exhausted") {
|
|
280
|
+
return "plan review exhausted its bounded revision rounds";
|
|
281
|
+
}
|
|
282
|
+
if (receipt.status === "failed")
|
|
283
|
+
return "plan review failed";
|
|
284
|
+
return "plan review requires attention";
|
|
285
|
+
}
|
|
102
286
|
export function applySupervisorPlanProposal(input) {
|
|
103
287
|
const coordRoot = resolve(input.coordRoot);
|
|
104
288
|
const policy = input.record.intent.replanning;
|
|
@@ -112,6 +296,18 @@ export function applySupervisorPlanProposal(input) {
|
|
|
112
296
|
!["apply", "complete"].includes(plan.proposal.decision)) {
|
|
113
297
|
throw new Error(`supervisor plan ${input.planId} cannot be applied from ${plan.status}`);
|
|
114
298
|
}
|
|
299
|
+
if (policy.review) {
|
|
300
|
+
const receipt = readSupervisorPlanReviewReceipt(coordRoot, input.record.intent.id, input.planId);
|
|
301
|
+
if (receipt?.status !== "passed") {
|
|
302
|
+
throw new Error(`supervisor plan ${input.planId} has no passed review receipt`);
|
|
303
|
+
}
|
|
304
|
+
if (candidateDigest(plan.proposal) !== receipt.candidate_sha256) {
|
|
305
|
+
throw new Error(`supervisor plan ${input.planId} proposal does not match its passed review`);
|
|
306
|
+
}
|
|
307
|
+
if (!plan.events.some((event) => event.event === "plan.reviewed")) {
|
|
308
|
+
throw new Error(`supervisor plan ${input.planId} has no reviewed authority event`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
115
311
|
if (plan.request.prior_root_work_id !== input.record.projection.root_work_id) {
|
|
116
312
|
throw new Error(`supervisor plan ${input.planId} targets stale root ${plan.request.prior_root_work_id}`);
|
|
117
313
|
}
|
|
@@ -244,7 +440,8 @@ function plannerScript(coordRoot, record, planId, trigger) {
|
|
|
244
440
|
: "Return decision=apply with a replacement immutable root graph, or decision=attention when human judgment is required.",
|
|
245
441
|
"Never claim mission completion unless the frozen mission acceptance is met. Never request a workflow outside the frozen template catalog.",
|
|
246
442
|
"Dependencies may name an active work ID or an earlier key in your proposed work array.",
|
|
247
|
-
|
|
443
|
+
`Every proposed item must be reachable from root. ${PROPOSAL_ROOT_RULE}.`,
|
|
444
|
+
"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.",
|
|
248
445
|
`Plan id: ${planId}`,
|
|
249
446
|
`Planning trigger: ${trigger}`,
|
|
250
447
|
`Goal: ${record.intent.title}`,
|
|
@@ -288,43 +485,7 @@ function plannerScript(coordRoot, record, planId, trigger) {
|
|
|
288
485
|
: null,
|
|
289
486
|
})))}`,
|
|
290
487
|
].join("\n\n");
|
|
291
|
-
const schema =
|
|
292
|
-
type: "object",
|
|
293
|
-
properties: {
|
|
294
|
-
decision: {
|
|
295
|
-
type: "string",
|
|
296
|
-
enum: record.intent.mission ? ["apply", "complete", "attention"] : ["apply", "attention"],
|
|
297
|
-
},
|
|
298
|
-
rationale: { type: "string" },
|
|
299
|
-
root: { type: "string" },
|
|
300
|
-
work: {
|
|
301
|
-
type: "array",
|
|
302
|
-
items: {
|
|
303
|
-
type: "object",
|
|
304
|
-
properties: {
|
|
305
|
-
key: { type: "string" },
|
|
306
|
-
title: { type: "string" },
|
|
307
|
-
objective: { type: "string" },
|
|
308
|
-
acceptance: { type: "array", items: { type: "string" } },
|
|
309
|
-
dependencies: { type: "array", items: { type: "string" } },
|
|
310
|
-
template: { type: "string" },
|
|
311
|
-
},
|
|
312
|
-
required: ["key", "title", "objective", "acceptance", "dependencies", "template"],
|
|
313
|
-
},
|
|
314
|
-
},
|
|
315
|
-
milestone: {
|
|
316
|
-
type: "object",
|
|
317
|
-
properties: {
|
|
318
|
-
sequence: { type: "number" },
|
|
319
|
-
title: { type: "string" },
|
|
320
|
-
objective: { type: "string" },
|
|
321
|
-
acceptance: { type: "array", items: { type: "string" } },
|
|
322
|
-
},
|
|
323
|
-
required: ["sequence", "title", "objective", "acceptance"],
|
|
324
|
-
},
|
|
325
|
-
},
|
|
326
|
-
required: ["decision", "rationale", "root", "work"],
|
|
327
|
-
};
|
|
488
|
+
const schema = planProposalSchema(record, trigger);
|
|
328
489
|
return [
|
|
329
490
|
`export const meta = ${JSON.stringify({ name: `replan-${planId}` })};`,
|
|
330
491
|
"export default async ({ agent, stage }) => {",
|
|
@@ -342,7 +503,500 @@ function plannerScript(coordRoot, record, planId, trigger) {
|
|
|
342
503
|
"",
|
|
343
504
|
].join("\n");
|
|
344
505
|
}
|
|
345
|
-
function
|
|
506
|
+
function plannerReviewScript(record, planId, initialCandidate, trigger) {
|
|
507
|
+
const policy = record.intent.replanning;
|
|
508
|
+
const review = policy?.review;
|
|
509
|
+
if (!policy || !review)
|
|
510
|
+
throw new Error(`supervisor ${record.intent.id} does not review plans`);
|
|
511
|
+
const proposalSchema = planProposalSchema(record, trigger);
|
|
512
|
+
const reviewerSchema = {
|
|
513
|
+
type: "object",
|
|
514
|
+
properties: {
|
|
515
|
+
verdict: { type: "string", enum: ["approve", "revise", "attention"] },
|
|
516
|
+
rationale: { type: "string", minLength: 1, maxLength: MAX_REASON },
|
|
517
|
+
findings: {
|
|
518
|
+
type: "array",
|
|
519
|
+
maxItems: 50,
|
|
520
|
+
items: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: {
|
|
523
|
+
code: {
|
|
524
|
+
type: "string",
|
|
525
|
+
minLength: 1,
|
|
526
|
+
maxLength: 100,
|
|
527
|
+
pattern: "^[a-z][a-z0-9._-]*$",
|
|
528
|
+
},
|
|
529
|
+
severity: { type: "string", enum: ["blocking", "advisory"] },
|
|
530
|
+
summary: { type: "string", minLength: 1, maxLength: 1_000 },
|
|
531
|
+
recommendation: { type: "string", minLength: 1, maxLength: 1_000 },
|
|
532
|
+
},
|
|
533
|
+
required: ["code", "severity", "summary", "recommendation"],
|
|
534
|
+
additionalProperties: false,
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
required: ["verdict", "rationale", "findings"],
|
|
539
|
+
additionalProperties: false,
|
|
540
|
+
};
|
|
541
|
+
return [
|
|
542
|
+
`export const meta = ${JSON.stringify({ name: `review-${planId}` })};`,
|
|
543
|
+
"export default async ({ agent, stage }) => {",
|
|
544
|
+
` const reviewers = ${JSON.stringify(review.reviewer_specialists)};`,
|
|
545
|
+
` const planner = ${JSON.stringify(policy.planner_specialist)};`,
|
|
546
|
+
` const maxRevisionRounds = ${JSON.stringify(review.max_revision_rounds)};`,
|
|
547
|
+
` const proposalSchema = ${JSON.stringify(proposalSchema)};`,
|
|
548
|
+
` const reviewerSchema = ${JSON.stringify(reviewerSchema)};`,
|
|
549
|
+
` const goalContext = ${JSON.stringify({
|
|
550
|
+
plan_id: planId,
|
|
551
|
+
trigger,
|
|
552
|
+
goal: record.intent.title,
|
|
553
|
+
mission: record.intent.mission ?? null,
|
|
554
|
+
original_root: record.intent.root_work_id,
|
|
555
|
+
active_root: record.projection.root_work_id,
|
|
556
|
+
proposal_root_rule: PROPOSAL_ROOT_RULE,
|
|
557
|
+
templates: policy.templates,
|
|
558
|
+
})};`,
|
|
559
|
+
` let candidate = ${JSON.stringify(initialCandidate)};`,
|
|
560
|
+
" const rounds = [];",
|
|
561
|
+
" const aggregate = (items) => {",
|
|
562
|
+
" if (items.some((item) => item.verdict === 'attention')) return 'attention';",
|
|
563
|
+
" if (items.some((item) => item.verdict === 'revise')) return 'revise';",
|
|
564
|
+
" if (items.some((item) => item.findings.some((finding) => finding.severity === 'blocking'))) return 'revise';",
|
|
565
|
+
" return 'approved';",
|
|
566
|
+
" };",
|
|
567
|
+
" for (let round = 1; round <= maxRevisionRounds + 1; round++) {",
|
|
568
|
+
" stage('Review round ' + round);",
|
|
569
|
+
" const reviewersOut = await Promise.all(reviewers.map(async (specialist) => {",
|
|
570
|
+
" const prompt = [",
|
|
571
|
+
" 'Review this bounded supervisor plan candidate independently.',",
|
|
572
|
+
" 'Return approve only when the candidate is complete, scoped, and satisfies the goal context.',",
|
|
573
|
+
" 'Return revise for blocking defects. Return attention when human judgment is required.',",
|
|
574
|
+
" 'Apply proposal_root_rule exactly; never require proposal.root to equal active_root.',",
|
|
575
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
576
|
+
" 'Candidate: ' + JSON.stringify(candidate)",
|
|
577
|
+
" ].join('\\n\\n');",
|
|
578
|
+
" const result = await agent(prompt, {",
|
|
579
|
+
" specialist,",
|
|
580
|
+
" schema: reviewerSchema,",
|
|
581
|
+
" label: 'Review plan candidate as ' + specialist",
|
|
582
|
+
" });",
|
|
583
|
+
" return { specialist, ...result };",
|
|
584
|
+
" }));",
|
|
585
|
+
" const roundRecord = { round, candidate, reviewers: reviewersOut, outcome: aggregate(reviewersOut) };",
|
|
586
|
+
" rounds.push(roundRecord);",
|
|
587
|
+
" if (roundRecord.outcome === 'approved' || roundRecord.outcome === 'attention') break;",
|
|
588
|
+
" if (round > maxRevisionRounds) break;",
|
|
589
|
+
" stage('Revise round ' + round);",
|
|
590
|
+
" const revisionPrompt = [",
|
|
591
|
+
" 'Revise this supervisor plan candidate. Return a complete replacement candidate, not a patch.',",
|
|
592
|
+
" 'Do not merge reviewer text mechanically; satisfy the blocking findings within the frozen goal and template constraints.',",
|
|
593
|
+
" 'Apply proposal_root_rule exactly; never require proposal.root to equal active_root.',",
|
|
594
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
595
|
+
" 'Current candidate: ' + JSON.stringify(candidate),",
|
|
596
|
+
" 'Reviewer findings: ' + JSON.stringify(reviewersOut)",
|
|
597
|
+
" ].join('\\n\\n');",
|
|
598
|
+
" roundRecord.revision_candidate = await agent(revisionPrompt, {",
|
|
599
|
+
" specialist: planner,",
|
|
600
|
+
" schema: proposalSchema,",
|
|
601
|
+
" label: 'Revise bounded replacement plan'",
|
|
602
|
+
" });",
|
|
603
|
+
" candidate = roundRecord.revision_candidate;",
|
|
604
|
+
" }",
|
|
605
|
+
" return { rounds };",
|
|
606
|
+
"};",
|
|
607
|
+
"",
|
|
608
|
+
].join("\n");
|
|
609
|
+
}
|
|
610
|
+
function normalizeReviewReceipt(input) {
|
|
611
|
+
const review = input.policy.review;
|
|
612
|
+
if (!review)
|
|
613
|
+
throw new Error("missing review policy");
|
|
614
|
+
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
|
|
615
|
+
throw new Error("review result must be an object");
|
|
616
|
+
}
|
|
617
|
+
const raw = input.raw;
|
|
618
|
+
if (!Array.isArray(raw.rounds))
|
|
619
|
+
throw new Error("review result rounds must be an array");
|
|
620
|
+
if (raw.rounds.length < 1 || raw.rounds.length > review.max_revision_rounds + 1) {
|
|
621
|
+
throw new Error("review result has an invalid number of rounds");
|
|
622
|
+
}
|
|
623
|
+
const rounds = [];
|
|
624
|
+
let candidate = input.initialCandidate;
|
|
625
|
+
let status;
|
|
626
|
+
for (const [index, rawRound] of raw.rounds.entries()) {
|
|
627
|
+
if (!rawRound || typeof rawRound !== "object" || Array.isArray(rawRound)) {
|
|
628
|
+
throw new Error(`review round ${index + 1} must be an object`);
|
|
629
|
+
}
|
|
630
|
+
if (status)
|
|
631
|
+
throw new Error("review result contains rounds after a terminal outcome");
|
|
632
|
+
const value = rawRound;
|
|
633
|
+
const roundNumber = positive(value.round, "review round", review.max_revision_rounds + 1);
|
|
634
|
+
if (roundNumber !== index + 1)
|
|
635
|
+
throw new Error(`review round must be ${index + 1}`);
|
|
636
|
+
const reviewers = normalizeReviewers(value.reviewers, review.reviewer_specialists);
|
|
637
|
+
const outcome = aggregateReviewers(reviewers);
|
|
638
|
+
const round = {
|
|
639
|
+
round: roundNumber,
|
|
640
|
+
candidate_sha256: candidateDigest(candidate),
|
|
641
|
+
reviewers,
|
|
642
|
+
outcome,
|
|
643
|
+
};
|
|
644
|
+
if (outcome === "approved") {
|
|
645
|
+
status = "passed";
|
|
646
|
+
}
|
|
647
|
+
else if (outcome === "attention") {
|
|
648
|
+
status = "attention";
|
|
649
|
+
}
|
|
650
|
+
else if (roundNumber > review.max_revision_rounds) {
|
|
651
|
+
status = "revision_exhausted";
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
const revision = normalizeProposal(input.request.id, value.revision_candidate, input.record, input.history.materialized_work_ids.length, input.trigger);
|
|
655
|
+
round.revision_workflow_run_id = reviewWorkflowRunId(input.request.id);
|
|
656
|
+
if (candidateDigest(revision) === candidateDigest(candidate)) {
|
|
657
|
+
status = "attention";
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
candidate = revision;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
rounds.push(round);
|
|
664
|
+
}
|
|
665
|
+
status ??= "attention";
|
|
666
|
+
return {
|
|
667
|
+
schema_version: SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
668
|
+
plan_id: input.request.id,
|
|
669
|
+
status,
|
|
670
|
+
candidate_sha256: candidateDigest(candidate),
|
|
671
|
+
final_candidate: candidate,
|
|
672
|
+
rounds,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function normalizeReviewers(raw, expectedSpecialists) {
|
|
676
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length) {
|
|
677
|
+
throw new Error("reviewer result count does not match the frozen review policy");
|
|
678
|
+
}
|
|
679
|
+
return raw.map((value, index) => {
|
|
680
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
681
|
+
throw new Error(`reviewer result ${index + 1} must be an object`);
|
|
682
|
+
}
|
|
683
|
+
const item = value;
|
|
684
|
+
const specialist = bounded(item.specialist, `reviewer ${index + 1} specialist`, 100);
|
|
685
|
+
if (specialist !== expectedSpecialists[index]) {
|
|
686
|
+
throw new Error(`reviewer ${index + 1} does not match the frozen review order`);
|
|
687
|
+
}
|
|
688
|
+
const findings = normalizeFindings(item.findings, specialist);
|
|
689
|
+
return {
|
|
690
|
+
specialist,
|
|
691
|
+
verdict: enumValue(item.verdict, ["approve", "revise", "attention"], "review verdict"),
|
|
692
|
+
rationale: bounded(item.rationale, "review rationale", MAX_REASON),
|
|
693
|
+
findings,
|
|
694
|
+
};
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
function normalizeFindings(raw, specialist) {
|
|
698
|
+
if (!Array.isArray(raw) || raw.length > 50) {
|
|
699
|
+
throw new Error(`review findings from ${specialist} must contain at most 50 items`);
|
|
700
|
+
}
|
|
701
|
+
return raw.map((value, index) => {
|
|
702
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
703
|
+
throw new Error(`review finding ${index + 1} from ${specialist} must be an object`);
|
|
704
|
+
}
|
|
705
|
+
const item = value;
|
|
706
|
+
return {
|
|
707
|
+
code: findingCode(item.code, specialist, index),
|
|
708
|
+
severity: enumValue(item.severity, ["blocking", "advisory"], "review finding severity"),
|
|
709
|
+
summary: bounded(item.summary, "review finding summary", 1_000),
|
|
710
|
+
recommendation: bounded(item.recommendation, "review finding recommendation", 1_000),
|
|
711
|
+
};
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function findingCode(value, specialist, index) {
|
|
715
|
+
const code = bounded(value, `review finding ${index + 1} code from ${specialist}`, 100);
|
|
716
|
+
if (!/^[a-z][a-z0-9._-]*$/.test(code)) {
|
|
717
|
+
throw new Error(`review finding ${index + 1} code from ${specialist} must be a stable lowercase identifier`);
|
|
718
|
+
}
|
|
719
|
+
return code;
|
|
720
|
+
}
|
|
721
|
+
function aggregateReviewers(reviewers) {
|
|
722
|
+
if (reviewers.length < 1)
|
|
723
|
+
return "failed";
|
|
724
|
+
if (reviewers.some((reviewer) => reviewer.verdict === "attention"))
|
|
725
|
+
return "attention";
|
|
726
|
+
if (reviewers.some((reviewer) => reviewer.verdict === "revise" ||
|
|
727
|
+
reviewer.findings.some((finding) => finding.severity === "blocking"))) {
|
|
728
|
+
return "revise";
|
|
729
|
+
}
|
|
730
|
+
return "approved";
|
|
731
|
+
}
|
|
732
|
+
function reconstructReviewReceiptFromProof(input) {
|
|
733
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
734
|
+
const proofPath = join(input.coordRoot, ".harnery", "workflows", runId, "proof.json");
|
|
735
|
+
if (!existsSync(proofPath))
|
|
736
|
+
return undefined;
|
|
737
|
+
const proof = readWorkflowProof(input.coordRoot, runId);
|
|
738
|
+
if (proof.run.status !== "succeeded")
|
|
739
|
+
return undefined;
|
|
740
|
+
const raw = recoverReviewWorkflowResult(input.coordRoot, input.record.intent.id, input.request.id, proof, input.policy.review.reviewer_specialists);
|
|
741
|
+
assertResultDigest(raw.result, proof.run.result, runId);
|
|
742
|
+
return normalizeReviewReceipt({
|
|
743
|
+
raw: raw.result,
|
|
744
|
+
initialCandidate: raw.initialCandidate,
|
|
745
|
+
record: input.record,
|
|
746
|
+
request: input.request,
|
|
747
|
+
history: input.history,
|
|
748
|
+
trigger: input.trigger,
|
|
749
|
+
policy: input.policy,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function recoverReviewWorkflowResult(coordRoot, goalId, planId, proof, expectedSpecialists) {
|
|
753
|
+
const inputPath = reviewInputPath(coordRoot, goalId, planId);
|
|
754
|
+
const reviewInput = readBoundedJson(inputPath, "supervisor plan review input");
|
|
755
|
+
const journal = readVerifiedWorkflowJournal(coordRoot, reviewWorkflowRunId(planId), proof);
|
|
756
|
+
const starts = new Map();
|
|
757
|
+
const byRound = new Map();
|
|
758
|
+
let currentCandidate = reviewInput.candidate;
|
|
759
|
+
for (const line of journal.split("\n")) {
|
|
760
|
+
if (!line.trim())
|
|
761
|
+
continue;
|
|
762
|
+
const event = JSON.parse(line);
|
|
763
|
+
if (event.event === "agent.start" && typeof event.id === "string") {
|
|
764
|
+
starts.set(event.id, {
|
|
765
|
+
specialist: typeof event.specialist === "string" ? event.specialist : undefined,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (event.event !== "agent.end" || event.result_kind !== "json")
|
|
769
|
+
continue;
|
|
770
|
+
const round = workflowJournalRound(event.stage);
|
|
771
|
+
if (!round)
|
|
772
|
+
continue;
|
|
773
|
+
let record = byRound.get(round.round);
|
|
774
|
+
if (!record) {
|
|
775
|
+
record = { round: round.round, candidate: currentCandidate, reviewers: [] };
|
|
776
|
+
byRound.set(round.round, record);
|
|
777
|
+
}
|
|
778
|
+
if (round.kind === "review") {
|
|
779
|
+
const specialist = typeof event.id === "string" ? starts.get(event.id)?.specialist : undefined;
|
|
780
|
+
if (!event.result || typeof event.result !== "object" || Array.isArray(event.result)) {
|
|
781
|
+
throw new Error("supervisor plan review journal contains an invalid reviewer result");
|
|
782
|
+
}
|
|
783
|
+
record.reviewers.push({
|
|
784
|
+
specialist,
|
|
785
|
+
...event.result,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
record.revision_candidate = event.result;
|
|
790
|
+
currentCandidate = event.result;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const rounds = [...byRound.entries()]
|
|
794
|
+
.sort(([left], [right]) => left - right)
|
|
795
|
+
.map(([, value]) => {
|
|
796
|
+
const reviewers = orderRecoveredReviewers(value.reviewers, expectedSpecialists);
|
|
797
|
+
const round = {
|
|
798
|
+
round: value.round,
|
|
799
|
+
candidate: value.candidate,
|
|
800
|
+
reviewers,
|
|
801
|
+
outcome: aggregateRecoveredReviewers(reviewers),
|
|
802
|
+
};
|
|
803
|
+
if (value.revision_candidate !== undefined) {
|
|
804
|
+
round.revision_candidate = value.revision_candidate;
|
|
805
|
+
}
|
|
806
|
+
return round;
|
|
807
|
+
});
|
|
808
|
+
return {
|
|
809
|
+
initialCandidate: reviewInput.candidate,
|
|
810
|
+
result: { rounds },
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function readVerifiedWorkflowJournal(coordRoot, runId, proof) {
|
|
814
|
+
const journalPath = join(coordRoot, ".harnery", "workflows", runId, proof.integrity.journal.path);
|
|
815
|
+
const journal = readFileSync(journalPath, "utf8");
|
|
816
|
+
const bytes = Buffer.byteLength(journal);
|
|
817
|
+
if (bytes <= 0 || bytes > MAX_RECORD_BYTES || bytes !== proof.integrity.journal.bytes) {
|
|
818
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
819
|
+
}
|
|
820
|
+
const sha256 = createHash("sha256").update(journal).digest("hex");
|
|
821
|
+
if (sha256 !== proof.integrity.journal.sha256) {
|
|
822
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
823
|
+
}
|
|
824
|
+
return journal;
|
|
825
|
+
}
|
|
826
|
+
function orderRecoveredReviewers(raw, expectedSpecialists) {
|
|
827
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length)
|
|
828
|
+
return raw;
|
|
829
|
+
const expected = new Set(expectedSpecialists);
|
|
830
|
+
const bySpecialist = new Map();
|
|
831
|
+
for (const value of raw) {
|
|
832
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
833
|
+
return raw;
|
|
834
|
+
const specialist = value.specialist;
|
|
835
|
+
if (typeof specialist !== "string" ||
|
|
836
|
+
!expected.has(specialist) ||
|
|
837
|
+
bySpecialist.has(specialist)) {
|
|
838
|
+
return raw;
|
|
839
|
+
}
|
|
840
|
+
bySpecialist.set(specialist, value);
|
|
841
|
+
}
|
|
842
|
+
return expectedSpecialists.map((specialist) => bySpecialist.get(specialist));
|
|
843
|
+
}
|
|
844
|
+
function aggregateRecoveredReviewers(reviewers) {
|
|
845
|
+
if (reviewers.some((value) => value &&
|
|
846
|
+
typeof value === "object" &&
|
|
847
|
+
!Array.isArray(value) &&
|
|
848
|
+
value.verdict === "attention")) {
|
|
849
|
+
return "attention";
|
|
850
|
+
}
|
|
851
|
+
if (reviewers.some((value) => {
|
|
852
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
853
|
+
return true;
|
|
854
|
+
const reviewer = value;
|
|
855
|
+
return (reviewer.verdict === "revise" ||
|
|
856
|
+
(Array.isArray(reviewer.findings) &&
|
|
857
|
+
reviewer.findings.some((finding) => finding &&
|
|
858
|
+
typeof finding === "object" &&
|
|
859
|
+
!Array.isArray(finding) &&
|
|
860
|
+
finding.severity === "blocking")));
|
|
861
|
+
})) {
|
|
862
|
+
return "revise";
|
|
863
|
+
}
|
|
864
|
+
return "approved";
|
|
865
|
+
}
|
|
866
|
+
function assertResultDigest(raw, expected, runId) {
|
|
867
|
+
if (!expected) {
|
|
868
|
+
throw new Error(`supervisor plan review proof ${runId} is missing its result digest`);
|
|
869
|
+
}
|
|
870
|
+
const actual = digestResult(raw, "json");
|
|
871
|
+
if (actual.kind !== expected.kind ||
|
|
872
|
+
actual.sha256 !== expected.sha256 ||
|
|
873
|
+
actual.bytes !== expected.bytes) {
|
|
874
|
+
throw new Error(`supervisor plan review result does not match proof digest`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function workflowJournalRound(stage) {
|
|
878
|
+
if (typeof stage !== "string")
|
|
879
|
+
return undefined;
|
|
880
|
+
const review = /^Review round ([1-9][0-9]*)$/.exec(stage);
|
|
881
|
+
if (review)
|
|
882
|
+
return { kind: "review", round: Number(review[1]) };
|
|
883
|
+
const revision = /^Revise round ([1-9][0-9]*)$/.exec(stage);
|
|
884
|
+
if (revision)
|
|
885
|
+
return { kind: "revision", round: Number(revision[1]) };
|
|
886
|
+
return undefined;
|
|
887
|
+
}
|
|
888
|
+
function candidateDigest(candidate) {
|
|
889
|
+
return createHash("sha256")
|
|
890
|
+
.update(JSON.stringify(canonicalCandidate(candidate)))
|
|
891
|
+
.digest("hex");
|
|
892
|
+
}
|
|
893
|
+
function canonicalCandidate(candidate) {
|
|
894
|
+
return {
|
|
895
|
+
schema_version: candidate.schema_version,
|
|
896
|
+
plan_id: candidate.plan_id,
|
|
897
|
+
decision: candidate.decision,
|
|
898
|
+
rationale: candidate.rationale,
|
|
899
|
+
root: candidate.root,
|
|
900
|
+
work: candidate.work,
|
|
901
|
+
milestone: candidate.milestone,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function hasReviewRecoveryEvidence(coordRoot, goalId, planId) {
|
|
905
|
+
return (existsSync(reviewReceiptPath(coordRoot, goalId, planId)) ||
|
|
906
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewWorkflowRunId(planId), "proof.json")));
|
|
907
|
+
}
|
|
908
|
+
function planProposalSchema(record, trigger) {
|
|
909
|
+
const policy = record.intent.replanning;
|
|
910
|
+
if (!policy)
|
|
911
|
+
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
912
|
+
const rationale = { type: "string", minLength: 1, maxLength: MAX_REASON };
|
|
913
|
+
const workItem = {
|
|
914
|
+
type: "object",
|
|
915
|
+
properties: {
|
|
916
|
+
key: { type: "string", pattern: "^[a-z][a-z0-9-]{0,31}$", maxLength: 32 },
|
|
917
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
918
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
919
|
+
acceptance: {
|
|
920
|
+
type: "array",
|
|
921
|
+
maxItems: 50,
|
|
922
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
923
|
+
},
|
|
924
|
+
dependencies: {
|
|
925
|
+
type: "array",
|
|
926
|
+
maxItems: 50,
|
|
927
|
+
items: { type: "string", minLength: 1, maxLength: 100 },
|
|
928
|
+
},
|
|
929
|
+
template: { type: "string", enum: Object.keys(policy.templates), maxLength: 64 },
|
|
930
|
+
},
|
|
931
|
+
required: ["key", "title", "objective", "acceptance", "dependencies", "template"],
|
|
932
|
+
additionalProperties: false,
|
|
933
|
+
};
|
|
934
|
+
const milestone = {
|
|
935
|
+
type: "object",
|
|
936
|
+
properties: {
|
|
937
|
+
sequence: {
|
|
938
|
+
type: "number",
|
|
939
|
+
enum: [record.projection.milestones_completed + 1],
|
|
940
|
+
},
|
|
941
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
942
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
943
|
+
acceptance: {
|
|
944
|
+
type: "array",
|
|
945
|
+
minItems: 1,
|
|
946
|
+
maxItems: 50,
|
|
947
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
required: ["sequence", "title", "objective", "acceptance"],
|
|
951
|
+
additionalProperties: false,
|
|
952
|
+
};
|
|
953
|
+
const applyProperties = {
|
|
954
|
+
decision: { type: "string", enum: ["apply"] },
|
|
955
|
+
rationale,
|
|
956
|
+
root: {
|
|
957
|
+
type: "string",
|
|
958
|
+
minLength: 1,
|
|
959
|
+
maxLength: 32,
|
|
960
|
+
pattern: "^[a-z][a-z0-9-]{0,31}$",
|
|
961
|
+
},
|
|
962
|
+
work: {
|
|
963
|
+
type: "array",
|
|
964
|
+
minItems: 1,
|
|
965
|
+
maxItems: policy.max_work_items_per_plan,
|
|
966
|
+
items: workItem,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
const applyRequired = ["decision", "rationale", "root", "work"];
|
|
970
|
+
if (record.intent.mission) {
|
|
971
|
+
applyProperties.milestone = milestone;
|
|
972
|
+
applyRequired.push("milestone");
|
|
973
|
+
}
|
|
974
|
+
const apply = {
|
|
975
|
+
type: "object",
|
|
976
|
+
properties: applyProperties,
|
|
977
|
+
required: applyRequired,
|
|
978
|
+
additionalProperties: false,
|
|
979
|
+
};
|
|
980
|
+
const terminal = (decision) => ({
|
|
981
|
+
type: "object",
|
|
982
|
+
properties: {
|
|
983
|
+
decision: { type: "string", enum: [decision] },
|
|
984
|
+
rationale,
|
|
985
|
+
root: { type: "string", enum: [""] },
|
|
986
|
+
work: { type: "array", maxItems: 0 },
|
|
987
|
+
},
|
|
988
|
+
required: ["decision", "rationale", "root", "work"],
|
|
989
|
+
additionalProperties: false,
|
|
990
|
+
});
|
|
991
|
+
const branches = [];
|
|
992
|
+
if (!record.intent.mission || record.projection.milestones_remaining > 0)
|
|
993
|
+
branches.push(apply);
|
|
994
|
+
if (record.intent.mission && trigger === "milestone")
|
|
995
|
+
branches.push(terminal("complete"));
|
|
996
|
+
branches.push(terminal("attention"));
|
|
997
|
+
return { type: "object", oneOf: branches };
|
|
998
|
+
}
|
|
999
|
+
function normalizeProposal(planId, raw, record, previouslyApplied, trigger, preservedProposedAt) {
|
|
346
1000
|
const policy = record.intent.replanning;
|
|
347
1001
|
if (!policy)
|
|
348
1002
|
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -350,6 +1004,7 @@ function normalizeProposal(planId, raw, record, previouslyApplied, trigger) {
|
|
|
350
1004
|
throw new Error("planner result must be an object");
|
|
351
1005
|
}
|
|
352
1006
|
const value = raw;
|
|
1007
|
+
const proposedAt = preservedProposedAt ?? new Date().toISOString();
|
|
353
1008
|
const decision = enumValue(value.decision, ["apply", "complete", "attention"], "plan decision");
|
|
354
1009
|
const rationale = bounded(value.rationale, "plan rationale", MAX_REASON);
|
|
355
1010
|
const root = typeof value.root === "string" ? value.root.trim() : "";
|
|
@@ -369,7 +1024,7 @@ function normalizeProposal(planId, raw, record, previouslyApplied, trigger) {
|
|
|
369
1024
|
rationale,
|
|
370
1025
|
root: "",
|
|
371
1026
|
work: [],
|
|
372
|
-
proposed_at:
|
|
1027
|
+
proposed_at: proposedAt,
|
|
373
1028
|
};
|
|
374
1029
|
}
|
|
375
1030
|
const milestone = record.intent.mission
|
|
@@ -457,9 +1112,19 @@ function normalizeProposal(planId, raw, record, previouslyApplied, trigger) {
|
|
|
457
1112
|
root,
|
|
458
1113
|
work,
|
|
459
1114
|
...(milestone ? { milestone } : {}),
|
|
460
|
-
proposed_at:
|
|
1115
|
+
proposed_at: proposedAt,
|
|
461
1116
|
};
|
|
462
1117
|
}
|
|
1118
|
+
function persistedProposalTimestamp(raw) {
|
|
1119
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1120
|
+
throw new Error("persisted review candidate must be an object");
|
|
1121
|
+
}
|
|
1122
|
+
const value = raw.proposed_at;
|
|
1123
|
+
if (typeof value !== "string" || value.length > 40 || !Number.isFinite(Date.parse(value))) {
|
|
1124
|
+
throw new Error("persisted review candidate has an invalid proposed_at timestamp");
|
|
1125
|
+
}
|
|
1126
|
+
return value;
|
|
1127
|
+
}
|
|
463
1128
|
function normalizeMilestone(raw, expectedSequence) {
|
|
464
1129
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
465
1130
|
throw new Error("mission plan must contain a milestone object");
|
|
@@ -510,6 +1175,12 @@ function appendPlanEvent(coordRootRaw, goalId, planId, input) {
|
|
|
510
1175
|
appendFileSync(path, line, { encoding: "utf8", mode: 0o600 });
|
|
511
1176
|
chmodSync(path, 0o600);
|
|
512
1177
|
}
|
|
1178
|
+
function appendPlanEventIfMissing(coordRoot, goalId, planId, eventType, input) {
|
|
1179
|
+
const plan = readSupervisorPlan(coordRoot, goalId, planId);
|
|
1180
|
+
if (plan.events.some((event) => event.event === eventType))
|
|
1181
|
+
return;
|
|
1182
|
+
appendPlanEvent(coordRoot, goalId, planId, input);
|
|
1183
|
+
}
|
|
513
1184
|
function outcome(plan) {
|
|
514
1185
|
return {
|
|
515
1186
|
plan_id: plan.request.id,
|
|
@@ -561,6 +1232,31 @@ function planScriptPath(coordRoot, goalId, planId) {
|
|
|
561
1232
|
function planProposalPath(coordRoot, goalId, planId) {
|
|
562
1233
|
return join(planDir(coordRoot, goalId, planId), "proposal.json");
|
|
563
1234
|
}
|
|
1235
|
+
function reviewScriptPath(coordRoot, goalId, planId) {
|
|
1236
|
+
return join(planDir(coordRoot, goalId, planId), "review.mjs");
|
|
1237
|
+
}
|
|
1238
|
+
function reviewInputPath(coordRoot, goalId, planId) {
|
|
1239
|
+
return join(planDir(coordRoot, goalId, planId), "review-input.json");
|
|
1240
|
+
}
|
|
1241
|
+
function reviewReceiptPath(coordRoot, goalId, planId) {
|
|
1242
|
+
return join(planDir(coordRoot, goalId, planId), "review.json");
|
|
1243
|
+
}
|
|
1244
|
+
function reviewWorkflowRunId(planId) {
|
|
1245
|
+
return `${planId}-review`;
|
|
1246
|
+
}
|
|
1247
|
+
function readBoundedJson(path, label) {
|
|
1248
|
+
if (!existsSync(path))
|
|
1249
|
+
throw new Error(`${label} does not exist at ${path}`);
|
|
1250
|
+
const size = statSync(path).size;
|
|
1251
|
+
if (size <= 0 || size > MAX_RECORD_BYTES)
|
|
1252
|
+
throw new Error(`${label} has invalid size ${size}`);
|
|
1253
|
+
try {
|
|
1254
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
throw new Error(`cannot parse ${label} at ${path}: ${error.message}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
564
1260
|
function bounded(value, field, max) {
|
|
565
1261
|
if (typeof value !== "string")
|
|
566
1262
|
throw new Error(`${field} must be a string`);
|
|
@@ -571,6 +1267,12 @@ function bounded(value, field, max) {
|
|
|
571
1267
|
throw new Error(`${field} exceeds ${max} characters`);
|
|
572
1268
|
return normalized;
|
|
573
1269
|
}
|
|
1270
|
+
function positive(value, field, max) {
|
|
1271
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
1272
|
+
throw new Error(`${field} must be an integer from 1 to ${max}`);
|
|
1273
|
+
}
|
|
1274
|
+
return value;
|
|
1275
|
+
}
|
|
574
1276
|
function enumValue(value, values, field) {
|
|
575
1277
|
if (typeof value !== "string" || !values.includes(value)) {
|
|
576
1278
|
throw new Error(`${field} must be one of ${values.join(", ")}`);
|