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,9 +1,9 @@
|
|
|
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 { 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}$/;
|
|
@@ -21,37 +21,112 @@ export async function runSupervisorPlanner(input) {
|
|
|
21
21
|
generation: input.record.projection.plan_generation,
|
|
22
22
|
work: input.record.work,
|
|
23
23
|
});
|
|
24
|
+
const trigger = planTrigger(input.record);
|
|
24
25
|
const resumable = history.latest?.status === "resumable" &&
|
|
25
26
|
history.latest.request.trigger_fingerprint === triggerFingerprint &&
|
|
26
27
|
history.latest.request.prior_root_work_id === input.record.projection.root_work_id
|
|
27
28
|
? history.latest
|
|
28
29
|
: undefined;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const interrupted = !resumable &&
|
|
31
|
+
history.latest?.status === "interrupted" &&
|
|
32
|
+
history.latest.request.trigger_fingerprint === triggerFingerprint &&
|
|
33
|
+
history.latest.request.prior_root_work_id === input.record.projection.root_work_id &&
|
|
34
|
+
policy.review &&
|
|
35
|
+
hasReviewRecoveryEvidence(coordRoot, input.record.intent.id, history.latest.request.id)
|
|
36
|
+
? history.latest
|
|
37
|
+
: undefined;
|
|
38
|
+
const request = resumable || interrupted
|
|
39
|
+
? (resumable ?? interrupted).request
|
|
40
|
+
: createPlanRequest(coordRoot, input.record, history.plans.length + 1, triggerFingerprint, trigger);
|
|
32
41
|
const scriptPath = planScriptPath(coordRoot, input.record.intent.id, request.id);
|
|
33
42
|
if (resumable) {
|
|
34
43
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
35
44
|
event: "plan.resumed",
|
|
36
45
|
actor: input.actor,
|
|
37
|
-
reason: `resuming
|
|
46
|
+
reason: `resuming planning workflow after approval ${resumable.approval_id}`,
|
|
38
47
|
approval_id: resumable.approval_id,
|
|
39
48
|
});
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const report = await runWorkflow(scriptPath, {
|
|
44
|
-
...input.engine,
|
|
50
|
+
if (interrupted) {
|
|
51
|
+
const recovered = await recoverReviewedPlan({
|
|
45
52
|
coordRoot,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
onLog: input.onLog,
|
|
53
|
+
record: input.record,
|
|
54
|
+
request,
|
|
55
|
+
history,
|
|
56
|
+
trigger: request.trigger ?? "recovery",
|
|
57
|
+
policy,
|
|
58
|
+
actor: input.actor,
|
|
53
59
|
});
|
|
54
|
-
|
|
60
|
+
if (recovered)
|
|
61
|
+
return recovered;
|
|
62
|
+
}
|
|
63
|
+
let proposalWritten = false;
|
|
64
|
+
try {
|
|
65
|
+
const reviewRunId = reviewWorkflowRunId(request.id);
|
|
66
|
+
const resumingReview = Boolean(resumable &&
|
|
67
|
+
policy.review &&
|
|
68
|
+
existsSync(reviewInputPath(coordRoot, input.record.intent.id, request.id)) &&
|
|
69
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewRunId, "run.json")));
|
|
70
|
+
const reviewInput = resumingReview
|
|
71
|
+
? readBoundedJson(reviewInputPath(coordRoot, input.record.intent.id, request.id), "supervisor plan review input")
|
|
72
|
+
: undefined;
|
|
73
|
+
const rawProposal = reviewInput
|
|
74
|
+
? reviewInput.candidate
|
|
75
|
+
: (await runWorkflow(scriptPath, {
|
|
76
|
+
...input.engine,
|
|
77
|
+
coordRoot,
|
|
78
|
+
...(resumable
|
|
79
|
+
? { resumeRunId: request.workflow_run_id }
|
|
80
|
+
: { runId: request.workflow_run_id }),
|
|
81
|
+
specialists: input.record.intent.specialists,
|
|
82
|
+
maxAgents: 1,
|
|
83
|
+
concurrency: 1,
|
|
84
|
+
onLog: input.onLog,
|
|
85
|
+
})).result;
|
|
86
|
+
const proposal = normalizeProposal(request.id, rawProposal, input.record, history.materialized_work_ids.length, request.trigger ?? "recovery", reviewInput ? persistedProposalTimestamp(reviewInput.candidate) : undefined);
|
|
87
|
+
if (policy.review) {
|
|
88
|
+
if (proposal.decision === "attention") {
|
|
89
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
90
|
+
event: "plan.attention",
|
|
91
|
+
actor: input.actor,
|
|
92
|
+
reason: proposal.rationale,
|
|
93
|
+
});
|
|
94
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
95
|
+
}
|
|
96
|
+
let receipt;
|
|
97
|
+
try {
|
|
98
|
+
receipt = await ensurePlanReview({
|
|
99
|
+
coordRoot,
|
|
100
|
+
record: input.record,
|
|
101
|
+
request,
|
|
102
|
+
proposal,
|
|
103
|
+
history,
|
|
104
|
+
trigger: request.trigger ?? "recovery",
|
|
105
|
+
policy,
|
|
106
|
+
engine: input.engine,
|
|
107
|
+
actor: input.actor,
|
|
108
|
+
onLog: input.onLog,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof WorkflowParkedError)
|
|
113
|
+
throw error;
|
|
114
|
+
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
115
|
+
event: "plan.attention",
|
|
116
|
+
actor: input.actor,
|
|
117
|
+
reason: bounded(`plan review requires attention: ${error.message}`, "plan review attention", MAX_REASON),
|
|
118
|
+
});
|
|
119
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
120
|
+
}
|
|
121
|
+
return finalizeReviewedProposal({
|
|
122
|
+
coordRoot,
|
|
123
|
+
record: input.record,
|
|
124
|
+
request,
|
|
125
|
+
receipt,
|
|
126
|
+
policy,
|
|
127
|
+
actor: input.actor,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
55
130
|
writeExclusiveJson(planProposalPath(coordRoot, input.record.intent.id, request.id), proposal, "supervisor plan proposal");
|
|
56
131
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
57
132
|
event: "plan.proposed",
|
|
@@ -85,7 +160,7 @@ export async function runSupervisorPlanner(input) {
|
|
|
85
160
|
appendPlanEvent(coordRoot, input.record.intent.id, request.id, {
|
|
86
161
|
event: "plan.awaiting_approval",
|
|
87
162
|
actor: input.actor,
|
|
88
|
-
reason: `
|
|
163
|
+
reason: `planning workflow parked for approval ${error.approvalId}`,
|
|
89
164
|
approval_id: error.approvalId,
|
|
90
165
|
});
|
|
91
166
|
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, request.id));
|
|
@@ -98,22 +173,153 @@ export async function runSupervisorPlanner(input) {
|
|
|
98
173
|
throw error;
|
|
99
174
|
}
|
|
100
175
|
}
|
|
176
|
+
async function recoverReviewedPlan(input) {
|
|
177
|
+
const review = input.policy.review;
|
|
178
|
+
if (!review)
|
|
179
|
+
return undefined;
|
|
180
|
+
const existing = readSupervisorPlanReviewReceipt(input.coordRoot, input.record.intent.id, input.request.id);
|
|
181
|
+
const receipt = existing ??
|
|
182
|
+
reconstructReviewReceiptFromProof({
|
|
183
|
+
coordRoot: input.coordRoot,
|
|
184
|
+
record: input.record,
|
|
185
|
+
request: input.request,
|
|
186
|
+
history: input.history,
|
|
187
|
+
trigger: input.trigger,
|
|
188
|
+
policy: input.policy,
|
|
189
|
+
});
|
|
190
|
+
if (!receipt)
|
|
191
|
+
return undefined;
|
|
192
|
+
if (!existing) {
|
|
193
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), receipt, "supervisor plan review receipt");
|
|
194
|
+
}
|
|
195
|
+
return finalizeReviewedProposal({
|
|
196
|
+
coordRoot: input.coordRoot,
|
|
197
|
+
record: input.record,
|
|
198
|
+
request: input.request,
|
|
199
|
+
receipt,
|
|
200
|
+
policy: input.policy,
|
|
201
|
+
actor: input.actor,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function ensurePlanReview(input) {
|
|
205
|
+
const existing = readSupervisorPlanReviewReceipt(input.coordRoot, input.record.intent.id, input.request.id);
|
|
206
|
+
if (existing)
|
|
207
|
+
return existing;
|
|
208
|
+
const reconstructed = reconstructReviewReceiptFromProof(input);
|
|
209
|
+
if (reconstructed) {
|
|
210
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), reconstructed, "supervisor plan review receipt");
|
|
211
|
+
return reconstructed;
|
|
212
|
+
}
|
|
213
|
+
const reviewScript = reviewScriptPath(input.coordRoot, input.record.intent.id, input.request.id);
|
|
214
|
+
writeExclusiveJson(reviewInputPath(input.coordRoot, input.record.intent.id, input.request.id), { candidate: input.proposal }, "supervisor plan review input");
|
|
215
|
+
writeExclusive(reviewScript, plannerReviewScript(input.record, input.request.id, input.proposal, input.trigger), "supervisor plan review script");
|
|
216
|
+
const review = input.policy.review;
|
|
217
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
218
|
+
const runExists = existsSync(join(input.coordRoot, ".harnery", "workflows", runId, "run.json"));
|
|
219
|
+
const report = await runWorkflow(reviewScript, {
|
|
220
|
+
...input.engine,
|
|
221
|
+
coordRoot: input.coordRoot,
|
|
222
|
+
...(runExists ? { resumeRunId: runId } : { runId }),
|
|
223
|
+
specialists: input.record.intent.specialists,
|
|
224
|
+
maxAgents: review.reviewer_specialists.length * (review.max_revision_rounds + 1) +
|
|
225
|
+
review.max_revision_rounds,
|
|
226
|
+
concurrency: input.record.intent.limits.agent_concurrency,
|
|
227
|
+
onLog: input.onLog,
|
|
228
|
+
});
|
|
229
|
+
const receipt = normalizeReviewReceipt({
|
|
230
|
+
raw: report.result,
|
|
231
|
+
initialCandidate: input.proposal,
|
|
232
|
+
record: input.record,
|
|
233
|
+
request: input.request,
|
|
234
|
+
history: input.history,
|
|
235
|
+
trigger: input.trigger,
|
|
236
|
+
policy: input.policy,
|
|
237
|
+
});
|
|
238
|
+
writeExclusiveJson(reviewReceiptPath(input.coordRoot, input.record.intent.id, input.request.id), receipt, "supervisor plan review receipt");
|
|
239
|
+
return receipt;
|
|
240
|
+
}
|
|
241
|
+
function finalizeReviewedProposal(input) {
|
|
242
|
+
if (input.receipt.status !== "passed") {
|
|
243
|
+
const event = input.receipt.status === "failed" ? "plan.failed" : "plan.attention";
|
|
244
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, event, {
|
|
245
|
+
event,
|
|
246
|
+
actor: input.actor,
|
|
247
|
+
reason: reviewStatusReason(input.receipt),
|
|
248
|
+
});
|
|
249
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
250
|
+
}
|
|
251
|
+
// The review authority must become durable before the proposal can become
|
|
252
|
+
// visible. If the process stops after this event, the plan remains
|
|
253
|
+
// interrupted and proof-backed recovery can reconstruct the proposal. The
|
|
254
|
+
// opposite order can expose an unapprovable proposal with no recovery path.
|
|
255
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, "plan.reviewed", {
|
|
256
|
+
event: "plan.reviewed",
|
|
257
|
+
actor: input.actor,
|
|
258
|
+
reason: `plan review passed after ${input.receipt.rounds.length} round(s)`,
|
|
259
|
+
});
|
|
260
|
+
writeExclusiveJson(planProposalPath(input.coordRoot, input.record.intent.id, input.request.id), input.receipt.final_candidate, "supervisor plan proposal");
|
|
261
|
+
appendPlanEventIfMissing(input.coordRoot, input.record.intent.id, input.request.id, "plan.proposed", {
|
|
262
|
+
event: "plan.proposed",
|
|
263
|
+
actor: input.actor,
|
|
264
|
+
reason: input.receipt.final_candidate.rationale,
|
|
265
|
+
});
|
|
266
|
+
if (input.policy.auto_apply) {
|
|
267
|
+
return applySupervisorPlanProposal({
|
|
268
|
+
coordRoot: input.coordRoot,
|
|
269
|
+
record: input.record,
|
|
270
|
+
planId: input.request.id,
|
|
271
|
+
actor: input.actor,
|
|
272
|
+
reason: "proposal applied by frozen supervisor replanning policy",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return outcome(readSupervisorPlan(input.coordRoot, input.record.intent.id, input.request.id));
|
|
276
|
+
}
|
|
277
|
+
function reviewStatusReason(receipt) {
|
|
278
|
+
if (receipt.status === "revision_exhausted") {
|
|
279
|
+
return "plan review exhausted its bounded revision rounds";
|
|
280
|
+
}
|
|
281
|
+
if (receipt.status === "failed")
|
|
282
|
+
return "plan review failed";
|
|
283
|
+
return "plan review requires attention";
|
|
284
|
+
}
|
|
101
285
|
export function applySupervisorPlanProposal(input) {
|
|
102
286
|
const coordRoot = resolve(input.coordRoot);
|
|
103
287
|
const policy = input.record.intent.replanning;
|
|
104
288
|
if (!policy)
|
|
105
289
|
throw new Error(`supervisor ${input.record.intent.id} does not allow replanning`);
|
|
106
290
|
const plan = readSupervisorPlan(coordRoot, input.record.intent.id, input.planId);
|
|
107
|
-
if (plan.status === "applied")
|
|
291
|
+
if (plan.status === "applied" || plan.status === "completed")
|
|
108
292
|
return outcome(plan);
|
|
109
|
-
if (plan.status !== "proposed" ||
|
|
293
|
+
if (plan.status !== "proposed" ||
|
|
294
|
+
!plan.proposal ||
|
|
295
|
+
!["apply", "complete"].includes(plan.proposal.decision)) {
|
|
110
296
|
throw new Error(`supervisor plan ${input.planId} cannot be applied from ${plan.status}`);
|
|
111
297
|
}
|
|
298
|
+
if (policy.review) {
|
|
299
|
+
const receipt = readSupervisorPlanReviewReceipt(coordRoot, input.record.intent.id, input.planId);
|
|
300
|
+
if (receipt?.status !== "passed") {
|
|
301
|
+
throw new Error(`supervisor plan ${input.planId} has no passed review receipt`);
|
|
302
|
+
}
|
|
303
|
+
if (candidateDigest(plan.proposal) !== receipt.candidate_sha256) {
|
|
304
|
+
throw new Error(`supervisor plan ${input.planId} proposal does not match its passed review`);
|
|
305
|
+
}
|
|
306
|
+
if (!plan.events.some((event) => event.event === "plan.reviewed")) {
|
|
307
|
+
throw new Error(`supervisor plan ${input.planId} has no reviewed authority event`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
112
310
|
if (plan.request.prior_root_work_id !== input.record.projection.root_work_id) {
|
|
113
311
|
throw new Error(`supervisor plan ${input.planId} targets stale root ${plan.request.prior_root_work_id}`);
|
|
114
312
|
}
|
|
115
313
|
const history = readSupervisorPlans(coordRoot, input.record.intent.id, input.record.intent.root_work_id);
|
|
116
|
-
const proposal = normalizeProposal(input.planId, plan.proposal, input.record, history.materialized_work_ids.filter((workId) => !workId.startsWith(`${input.planId}-`)).length);
|
|
314
|
+
const proposal = normalizeProposal(input.planId, plan.proposal, input.record, history.materialized_work_ids.filter((workId) => !workId.startsWith(`${input.planId}-`)).length, plan.request.trigger ?? "recovery");
|
|
315
|
+
if (proposal.decision === "complete") {
|
|
316
|
+
appendPlanEvent(coordRoot, input.record.intent.id, input.planId, {
|
|
317
|
+
event: "plan.completed",
|
|
318
|
+
actor: input.actor,
|
|
319
|
+
reason: input.reason ?? proposal.rationale,
|
|
320
|
+
});
|
|
321
|
+
return outcome(readSupervisorPlan(coordRoot, input.record.intent.id, input.planId));
|
|
322
|
+
}
|
|
117
323
|
const ids = new Map();
|
|
118
324
|
const created = [];
|
|
119
325
|
for (const spec of proposal.work) {
|
|
@@ -195,7 +401,7 @@ export function rejectSupervisorPlanProposal(input) {
|
|
|
195
401
|
});
|
|
196
402
|
return outcome(readSupervisorPlan(input.coordRoot, input.goalId, input.planId));
|
|
197
403
|
}
|
|
198
|
-
function createPlanRequest(coordRoot, record, sequence, triggerFingerprint) {
|
|
404
|
+
function createPlanRequest(coordRoot, record, sequence, triggerFingerprint, trigger) {
|
|
199
405
|
const policy = record.intent.replanning;
|
|
200
406
|
if (!policy)
|
|
201
407
|
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -208,36 +414,43 @@ function createPlanRequest(coordRoot, record, sequence, triggerFingerprint) {
|
|
|
208
414
|
id: planId,
|
|
209
415
|
goal_id: record.intent.id,
|
|
210
416
|
sequence,
|
|
417
|
+
trigger,
|
|
211
418
|
trigger_fingerprint: triggerFingerprint,
|
|
212
419
|
prior_root_work_id: record.projection.root_work_id,
|
|
213
420
|
workflow_run_id: `${planId}-workflow`,
|
|
214
421
|
created_at: new Date().toISOString(),
|
|
215
422
|
};
|
|
423
|
+
const script = plannerScript(coordRoot, record, planId, trigger);
|
|
216
424
|
const dir = planDir(coordRoot, record.intent.id, planId);
|
|
217
425
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
218
426
|
chmodSync(dir, 0o700);
|
|
219
427
|
writeExclusiveJson(join(dir, "request.json"), request, "supervisor plan request");
|
|
220
|
-
const script = plannerScript(record, planId);
|
|
221
428
|
writeExclusive(join(dir, "planner.mjs"), script, "supervisor planner script");
|
|
222
429
|
return request;
|
|
223
430
|
}
|
|
224
|
-
function plannerScript(record, planId) {
|
|
431
|
+
function plannerScript(coordRoot, record, planId, trigger) {
|
|
225
432
|
const policy = record.intent.replanning;
|
|
226
433
|
if (!policy)
|
|
227
434
|
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
228
435
|
const prompt = [
|
|
229
|
-
"You are producing
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
436
|
+
"You are producing one bounded replacement plan or milestone decision for a durable supervised goal.",
|
|
437
|
+
record.intent.mission
|
|
438
|
+
? "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."
|
|
439
|
+
: "Return decision=apply with a replacement immutable root graph, or decision=attention when human judgment is required.",
|
|
440
|
+
"Never claim mission completion unless the frozen mission acceptance is met. Never request a workflow outside the frozen template catalog.",
|
|
233
441
|
"Dependencies may name an active work ID or an earlier key in your proposed work array.",
|
|
234
442
|
"Every proposed item must be reachable from root. The root must be a proposed key using a root-capable template.",
|
|
443
|
+
"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.",
|
|
235
444
|
`Plan id: ${planId}`,
|
|
445
|
+
`Planning trigger: ${trigger}`,
|
|
236
446
|
`Goal: ${record.intent.title}`,
|
|
447
|
+
`Mission: ${JSON.stringify(record.intent.mission ?? null)}`,
|
|
237
448
|
`Original root: ${record.intent.root_work_id}`,
|
|
238
449
|
`Active root: ${record.projection.root_work_id}`,
|
|
239
450
|
`Remaining work attempts: ${record.projection.attempts_remaining}`,
|
|
240
451
|
`Remaining replans: ${record.projection.replans_remaining}`,
|
|
452
|
+
`Completed milestones: ${record.projection.milestones_completed}`,
|
|
453
|
+
`Remaining milestones: ${record.projection.milestones_remaining}`,
|
|
241
454
|
`Latest planner outcome: ${JSON.stringify(record.plans.at(-1)
|
|
242
455
|
? {
|
|
243
456
|
status: record.plans.at(-1)?.status,
|
|
@@ -253,46 +466,490 @@ function plannerScript(record, planId) {
|
|
|
253
466
|
dependencies: work.intent.dependencies,
|
|
254
467
|
state: work.projection.state,
|
|
255
468
|
reason: work.projection.reason,
|
|
469
|
+
proof: work.projection.proof_path
|
|
470
|
+
? (() => {
|
|
471
|
+
const proof = readWorkflowProof(coordRoot, work.projection.latest_run_id);
|
|
472
|
+
return {
|
|
473
|
+
run_id: proof.run.id,
|
|
474
|
+
status: proof.run.status,
|
|
475
|
+
acceptance: proof.acceptance.summary,
|
|
476
|
+
repository: {
|
|
477
|
+
head_changed: proof.repository.drift.head_changed,
|
|
478
|
+
dirty_paths_added: proof.repository.drift.dirty_paths_added,
|
|
479
|
+
incomplete: proof.repository.drift.incomplete,
|
|
480
|
+
},
|
|
481
|
+
unknowns: proof.unknowns.map((unknown) => unknown.code),
|
|
482
|
+
};
|
|
483
|
+
})()
|
|
484
|
+
: null,
|
|
256
485
|
})))}`,
|
|
257
486
|
].join("\n\n");
|
|
258
|
-
const schema =
|
|
487
|
+
const schema = planProposalSchema(record);
|
|
488
|
+
return [
|
|
489
|
+
`export const meta = ${JSON.stringify({ name: `replan-${planId}` })};`,
|
|
490
|
+
"export default async ({ agent, stage }) => {",
|
|
491
|
+
` stage(${JSON.stringify(trigger === "initial"
|
|
492
|
+
? "Plan initial mission milestone"
|
|
493
|
+
: trigger === "milestone"
|
|
494
|
+
? "Reassess completed milestone"
|
|
495
|
+
: "Replan blocked goal")});`,
|
|
496
|
+
` return await agent(${JSON.stringify(prompt)}, ${JSON.stringify({
|
|
497
|
+
specialist: policy.planner_specialist,
|
|
498
|
+
schema,
|
|
499
|
+
label: "Produce bounded replacement plan",
|
|
500
|
+
})});`,
|
|
501
|
+
"};",
|
|
502
|
+
"",
|
|
503
|
+
].join("\n");
|
|
504
|
+
}
|
|
505
|
+
function plannerReviewScript(record, planId, initialCandidate, trigger) {
|
|
506
|
+
const policy = record.intent.replanning;
|
|
507
|
+
const review = policy?.review;
|
|
508
|
+
if (!policy || !review)
|
|
509
|
+
throw new Error(`supervisor ${record.intent.id} does not review plans`);
|
|
510
|
+
const proposalSchema = planProposalSchema(record);
|
|
511
|
+
const reviewerSchema = {
|
|
259
512
|
type: "object",
|
|
260
513
|
properties: {
|
|
261
|
-
|
|
514
|
+
verdict: { type: "string", enum: ["approve", "revise", "attention"] },
|
|
262
515
|
rationale: { type: "string" },
|
|
263
|
-
|
|
264
|
-
work: {
|
|
516
|
+
findings: {
|
|
265
517
|
type: "array",
|
|
266
518
|
items: {
|
|
267
519
|
type: "object",
|
|
268
520
|
properties: {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
dependencies: { type: "array", items: { type: "string" } },
|
|
274
|
-
template: { type: "string" },
|
|
521
|
+
code: { type: "string" },
|
|
522
|
+
severity: { type: "string", enum: ["blocking", "advisory"] },
|
|
523
|
+
summary: { type: "string" },
|
|
524
|
+
recommendation: { type: "string" },
|
|
275
525
|
},
|
|
276
|
-
required: ["
|
|
526
|
+
required: ["code", "severity", "summary", "recommendation"],
|
|
277
527
|
},
|
|
278
528
|
},
|
|
279
529
|
},
|
|
280
|
-
required: ["
|
|
530
|
+
required: ["verdict", "rationale", "findings"],
|
|
281
531
|
};
|
|
282
532
|
return [
|
|
283
|
-
`export const meta = ${JSON.stringify({ name: `
|
|
533
|
+
`export const meta = ${JSON.stringify({ name: `review-${planId}` })};`,
|
|
284
534
|
"export default async ({ agent, stage }) => {",
|
|
285
|
-
|
|
286
|
-
`
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
535
|
+
` const reviewers = ${JSON.stringify(review.reviewer_specialists)};`,
|
|
536
|
+
` const planner = ${JSON.stringify(policy.planner_specialist)};`,
|
|
537
|
+
` const maxRevisionRounds = ${JSON.stringify(review.max_revision_rounds)};`,
|
|
538
|
+
` const proposalSchema = ${JSON.stringify(proposalSchema)};`,
|
|
539
|
+
` const reviewerSchema = ${JSON.stringify(reviewerSchema)};`,
|
|
540
|
+
` const goalContext = ${JSON.stringify({
|
|
541
|
+
plan_id: planId,
|
|
542
|
+
trigger,
|
|
543
|
+
goal: record.intent.title,
|
|
544
|
+
mission: record.intent.mission ?? null,
|
|
545
|
+
original_root: record.intent.root_work_id,
|
|
546
|
+
active_root: record.projection.root_work_id,
|
|
547
|
+
templates: policy.templates,
|
|
548
|
+
})};`,
|
|
549
|
+
` let candidate = ${JSON.stringify(initialCandidate)};`,
|
|
550
|
+
" const rounds = [];",
|
|
551
|
+
" const aggregate = (items) => {",
|
|
552
|
+
" if (items.some((item) => item.verdict === 'attention')) return 'attention';",
|
|
553
|
+
" if (items.some((item) => item.verdict === 'revise')) return 'revise';",
|
|
554
|
+
" if (items.some((item) => item.findings.some((finding) => finding.severity === 'blocking'))) return 'revise';",
|
|
555
|
+
" return 'approved';",
|
|
556
|
+
" };",
|
|
557
|
+
" for (let round = 1; round <= maxRevisionRounds + 1; round++) {",
|
|
558
|
+
" stage('Review round ' + round);",
|
|
559
|
+
" const reviewersOut = await Promise.all(reviewers.map(async (specialist) => {",
|
|
560
|
+
" const prompt = [",
|
|
561
|
+
" 'Review this bounded supervisor plan candidate independently.',",
|
|
562
|
+
" 'Return approve only when the candidate is complete, scoped, and satisfies the goal context.',",
|
|
563
|
+
" 'Return revise for blocking defects. Return attention when human judgment is required.',",
|
|
564
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
565
|
+
" 'Candidate: ' + JSON.stringify(candidate)",
|
|
566
|
+
" ].join('\\n\\n');",
|
|
567
|
+
" const result = await agent(prompt, {",
|
|
568
|
+
" specialist,",
|
|
569
|
+
" schema: reviewerSchema,",
|
|
570
|
+
" label: 'Review plan candidate as ' + specialist",
|
|
571
|
+
" });",
|
|
572
|
+
" return { specialist, ...result };",
|
|
573
|
+
" }));",
|
|
574
|
+
" const roundRecord = { round, candidate, reviewers: reviewersOut, outcome: aggregate(reviewersOut) };",
|
|
575
|
+
" rounds.push(roundRecord);",
|
|
576
|
+
" if (roundRecord.outcome === 'approved' || roundRecord.outcome === 'attention') break;",
|
|
577
|
+
" if (round > maxRevisionRounds) break;",
|
|
578
|
+
" stage('Revise round ' + round);",
|
|
579
|
+
" const revisionPrompt = [",
|
|
580
|
+
" 'Revise this supervisor plan candidate. Return a complete replacement candidate, not a patch.',",
|
|
581
|
+
" 'Do not merge reviewer text mechanically; satisfy the blocking findings within the frozen goal and template constraints.',",
|
|
582
|
+
" 'Goal context: ' + JSON.stringify(goalContext),",
|
|
583
|
+
" 'Current candidate: ' + JSON.stringify(candidate),",
|
|
584
|
+
" 'Reviewer findings: ' + JSON.stringify(reviewersOut)",
|
|
585
|
+
" ].join('\\n\\n');",
|
|
586
|
+
" roundRecord.revision_candidate = await agent(revisionPrompt, {",
|
|
587
|
+
" specialist: planner,",
|
|
588
|
+
" schema: proposalSchema,",
|
|
589
|
+
" label: 'Revise bounded replacement plan'",
|
|
590
|
+
" });",
|
|
591
|
+
" candidate = roundRecord.revision_candidate;",
|
|
592
|
+
" }",
|
|
593
|
+
" return { rounds };",
|
|
291
594
|
"};",
|
|
292
595
|
"",
|
|
293
596
|
].join("\n");
|
|
294
597
|
}
|
|
295
|
-
function
|
|
598
|
+
function normalizeReviewReceipt(input) {
|
|
599
|
+
const review = input.policy.review;
|
|
600
|
+
if (!review)
|
|
601
|
+
throw new Error("missing review policy");
|
|
602
|
+
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
|
|
603
|
+
throw new Error("review result must be an object");
|
|
604
|
+
}
|
|
605
|
+
const raw = input.raw;
|
|
606
|
+
if (!Array.isArray(raw.rounds))
|
|
607
|
+
throw new Error("review result rounds must be an array");
|
|
608
|
+
if (raw.rounds.length < 1 || raw.rounds.length > review.max_revision_rounds + 1) {
|
|
609
|
+
throw new Error("review result has an invalid number of rounds");
|
|
610
|
+
}
|
|
611
|
+
const rounds = [];
|
|
612
|
+
let candidate = input.initialCandidate;
|
|
613
|
+
let status;
|
|
614
|
+
for (const [index, rawRound] of raw.rounds.entries()) {
|
|
615
|
+
if (!rawRound || typeof rawRound !== "object" || Array.isArray(rawRound)) {
|
|
616
|
+
throw new Error(`review round ${index + 1} must be an object`);
|
|
617
|
+
}
|
|
618
|
+
if (status)
|
|
619
|
+
throw new Error("review result contains rounds after a terminal outcome");
|
|
620
|
+
const value = rawRound;
|
|
621
|
+
const roundNumber = positive(value.round, "review round", review.max_revision_rounds + 1);
|
|
622
|
+
if (roundNumber !== index + 1)
|
|
623
|
+
throw new Error(`review round must be ${index + 1}`);
|
|
624
|
+
const reviewers = normalizeReviewers(value.reviewers, review.reviewer_specialists);
|
|
625
|
+
const outcome = aggregateReviewers(reviewers);
|
|
626
|
+
const round = {
|
|
627
|
+
round: roundNumber,
|
|
628
|
+
candidate_sha256: candidateDigest(candidate),
|
|
629
|
+
reviewers,
|
|
630
|
+
outcome,
|
|
631
|
+
};
|
|
632
|
+
if (outcome === "approved") {
|
|
633
|
+
status = "passed";
|
|
634
|
+
}
|
|
635
|
+
else if (outcome === "attention") {
|
|
636
|
+
status = "attention";
|
|
637
|
+
}
|
|
638
|
+
else if (roundNumber > review.max_revision_rounds) {
|
|
639
|
+
status = "revision_exhausted";
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const revision = normalizeProposal(input.request.id, value.revision_candidate, input.record, input.history.materialized_work_ids.length, input.trigger);
|
|
643
|
+
round.revision_workflow_run_id = reviewWorkflowRunId(input.request.id);
|
|
644
|
+
if (candidateDigest(revision) === candidateDigest(candidate)) {
|
|
645
|
+
status = "attention";
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
candidate = revision;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
rounds.push(round);
|
|
652
|
+
}
|
|
653
|
+
status ??= "attention";
|
|
654
|
+
return {
|
|
655
|
+
schema_version: SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
656
|
+
plan_id: input.request.id,
|
|
657
|
+
status,
|
|
658
|
+
candidate_sha256: candidateDigest(candidate),
|
|
659
|
+
final_candidate: candidate,
|
|
660
|
+
rounds,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function normalizeReviewers(raw, expectedSpecialists) {
|
|
664
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length) {
|
|
665
|
+
throw new Error("reviewer result count does not match the frozen review policy");
|
|
666
|
+
}
|
|
667
|
+
return raw.map((value, index) => {
|
|
668
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
669
|
+
throw new Error(`reviewer result ${index + 1} must be an object`);
|
|
670
|
+
}
|
|
671
|
+
const item = value;
|
|
672
|
+
const specialist = bounded(item.specialist, `reviewer ${index + 1} specialist`, 100);
|
|
673
|
+
if (specialist !== expectedSpecialists[index]) {
|
|
674
|
+
throw new Error(`reviewer ${index + 1} does not match the frozen review order`);
|
|
675
|
+
}
|
|
676
|
+
const findings = normalizeFindings(item.findings, specialist);
|
|
677
|
+
return {
|
|
678
|
+
specialist,
|
|
679
|
+
verdict: enumValue(item.verdict, ["approve", "revise", "attention"], "review verdict"),
|
|
680
|
+
rationale: bounded(item.rationale, "review rationale", MAX_REASON),
|
|
681
|
+
findings,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
function normalizeFindings(raw, specialist) {
|
|
686
|
+
if (!Array.isArray(raw) || raw.length > 50) {
|
|
687
|
+
throw new Error(`review findings from ${specialist} must contain at most 50 items`);
|
|
688
|
+
}
|
|
689
|
+
return raw.map((value, index) => {
|
|
690
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
691
|
+
throw new Error(`review finding ${index + 1} from ${specialist} must be an object`);
|
|
692
|
+
}
|
|
693
|
+
const item = value;
|
|
694
|
+
return {
|
|
695
|
+
code: findingCode(item.code, specialist, index),
|
|
696
|
+
severity: enumValue(item.severity, ["blocking", "advisory"], "review finding severity"),
|
|
697
|
+
summary: bounded(item.summary, "review finding summary", 1_000),
|
|
698
|
+
recommendation: bounded(item.recommendation, "review finding recommendation", 1_000),
|
|
699
|
+
};
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function findingCode(value, specialist, index) {
|
|
703
|
+
const code = bounded(value, `review finding ${index + 1} code from ${specialist}`, 100);
|
|
704
|
+
if (!/^[a-z][a-z0-9._-]*$/.test(code)) {
|
|
705
|
+
throw new Error(`review finding ${index + 1} code from ${specialist} must be a stable lowercase identifier`);
|
|
706
|
+
}
|
|
707
|
+
return code;
|
|
708
|
+
}
|
|
709
|
+
function aggregateReviewers(reviewers) {
|
|
710
|
+
if (reviewers.length < 1)
|
|
711
|
+
return "failed";
|
|
712
|
+
if (reviewers.some((reviewer) => reviewer.verdict === "attention"))
|
|
713
|
+
return "attention";
|
|
714
|
+
if (reviewers.some((reviewer) => reviewer.verdict === "revise" ||
|
|
715
|
+
reviewer.findings.some((finding) => finding.severity === "blocking"))) {
|
|
716
|
+
return "revise";
|
|
717
|
+
}
|
|
718
|
+
return "approved";
|
|
719
|
+
}
|
|
720
|
+
function reconstructReviewReceiptFromProof(input) {
|
|
721
|
+
const runId = reviewWorkflowRunId(input.request.id);
|
|
722
|
+
const proofPath = join(input.coordRoot, ".harnery", "workflows", runId, "proof.json");
|
|
723
|
+
if (!existsSync(proofPath))
|
|
724
|
+
return undefined;
|
|
725
|
+
const proof = readWorkflowProof(input.coordRoot, runId);
|
|
726
|
+
if (proof.run.status !== "succeeded")
|
|
727
|
+
return undefined;
|
|
728
|
+
const raw = recoverReviewWorkflowResult(input.coordRoot, input.record.intent.id, input.request.id, proof, input.policy.review.reviewer_specialists);
|
|
729
|
+
assertResultDigest(raw.result, proof.run.result, runId);
|
|
730
|
+
return normalizeReviewReceipt({
|
|
731
|
+
raw: raw.result,
|
|
732
|
+
initialCandidate: raw.initialCandidate,
|
|
733
|
+
record: input.record,
|
|
734
|
+
request: input.request,
|
|
735
|
+
history: input.history,
|
|
736
|
+
trigger: input.trigger,
|
|
737
|
+
policy: input.policy,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function recoverReviewWorkflowResult(coordRoot, goalId, planId, proof, expectedSpecialists) {
|
|
741
|
+
const inputPath = reviewInputPath(coordRoot, goalId, planId);
|
|
742
|
+
const reviewInput = readBoundedJson(inputPath, "supervisor plan review input");
|
|
743
|
+
const journal = readVerifiedWorkflowJournal(coordRoot, reviewWorkflowRunId(planId), proof);
|
|
744
|
+
const starts = new Map();
|
|
745
|
+
const byRound = new Map();
|
|
746
|
+
let currentCandidate = reviewInput.candidate;
|
|
747
|
+
for (const line of journal.split("\n")) {
|
|
748
|
+
if (!line.trim())
|
|
749
|
+
continue;
|
|
750
|
+
const event = JSON.parse(line);
|
|
751
|
+
if (event.event === "agent.start" && typeof event.id === "string") {
|
|
752
|
+
starts.set(event.id, {
|
|
753
|
+
specialist: typeof event.specialist === "string" ? event.specialist : undefined,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (event.event !== "agent.end" || event.result_kind !== "json")
|
|
757
|
+
continue;
|
|
758
|
+
const round = workflowJournalRound(event.stage);
|
|
759
|
+
if (!round)
|
|
760
|
+
continue;
|
|
761
|
+
let record = byRound.get(round.round);
|
|
762
|
+
if (!record) {
|
|
763
|
+
record = { round: round.round, candidate: currentCandidate, reviewers: [] };
|
|
764
|
+
byRound.set(round.round, record);
|
|
765
|
+
}
|
|
766
|
+
if (round.kind === "review") {
|
|
767
|
+
const specialist = typeof event.id === "string" ? starts.get(event.id)?.specialist : undefined;
|
|
768
|
+
if (!event.result || typeof event.result !== "object" || Array.isArray(event.result)) {
|
|
769
|
+
throw new Error("supervisor plan review journal contains an invalid reviewer result");
|
|
770
|
+
}
|
|
771
|
+
record.reviewers.push({
|
|
772
|
+
specialist,
|
|
773
|
+
...event.result,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
record.revision_candidate = event.result;
|
|
778
|
+
currentCandidate = event.result;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const rounds = [...byRound.entries()]
|
|
782
|
+
.sort(([left], [right]) => left - right)
|
|
783
|
+
.map(([, value]) => {
|
|
784
|
+
const reviewers = orderRecoveredReviewers(value.reviewers, expectedSpecialists);
|
|
785
|
+
const round = {
|
|
786
|
+
round: value.round,
|
|
787
|
+
candidate: value.candidate,
|
|
788
|
+
reviewers,
|
|
789
|
+
outcome: aggregateRecoveredReviewers(reviewers),
|
|
790
|
+
};
|
|
791
|
+
if (value.revision_candidate !== undefined) {
|
|
792
|
+
round.revision_candidate = value.revision_candidate;
|
|
793
|
+
}
|
|
794
|
+
return round;
|
|
795
|
+
});
|
|
796
|
+
return {
|
|
797
|
+
initialCandidate: reviewInput.candidate,
|
|
798
|
+
result: { rounds },
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function readVerifiedWorkflowJournal(coordRoot, runId, proof) {
|
|
802
|
+
const journalPath = join(coordRoot, ".harnery", "workflows", runId, proof.integrity.journal.path);
|
|
803
|
+
const journal = readFileSync(journalPath, "utf8");
|
|
804
|
+
const bytes = Buffer.byteLength(journal);
|
|
805
|
+
if (bytes <= 0 || bytes > MAX_RECORD_BYTES || bytes !== proof.integrity.journal.bytes) {
|
|
806
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
807
|
+
}
|
|
808
|
+
const sha256 = createHash("sha256").update(journal).digest("hex");
|
|
809
|
+
if (sha256 !== proof.integrity.journal.sha256) {
|
|
810
|
+
throw new Error(`supervisor plan review journal does not match proof integrity`);
|
|
811
|
+
}
|
|
812
|
+
return journal;
|
|
813
|
+
}
|
|
814
|
+
function orderRecoveredReviewers(raw, expectedSpecialists) {
|
|
815
|
+
if (!Array.isArray(raw) || raw.length !== expectedSpecialists.length)
|
|
816
|
+
return raw;
|
|
817
|
+
const expected = new Set(expectedSpecialists);
|
|
818
|
+
const bySpecialist = new Map();
|
|
819
|
+
for (const value of raw) {
|
|
820
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
821
|
+
return raw;
|
|
822
|
+
const specialist = value.specialist;
|
|
823
|
+
if (typeof specialist !== "string" ||
|
|
824
|
+
!expected.has(specialist) ||
|
|
825
|
+
bySpecialist.has(specialist)) {
|
|
826
|
+
return raw;
|
|
827
|
+
}
|
|
828
|
+
bySpecialist.set(specialist, value);
|
|
829
|
+
}
|
|
830
|
+
return expectedSpecialists.map((specialist) => bySpecialist.get(specialist));
|
|
831
|
+
}
|
|
832
|
+
function aggregateRecoveredReviewers(reviewers) {
|
|
833
|
+
if (reviewers.some((value) => value &&
|
|
834
|
+
typeof value === "object" &&
|
|
835
|
+
!Array.isArray(value) &&
|
|
836
|
+
value.verdict === "attention")) {
|
|
837
|
+
return "attention";
|
|
838
|
+
}
|
|
839
|
+
if (reviewers.some((value) => {
|
|
840
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
841
|
+
return true;
|
|
842
|
+
const reviewer = value;
|
|
843
|
+
return (reviewer.verdict === "revise" ||
|
|
844
|
+
(Array.isArray(reviewer.findings) &&
|
|
845
|
+
reviewer.findings.some((finding) => finding &&
|
|
846
|
+
typeof finding === "object" &&
|
|
847
|
+
!Array.isArray(finding) &&
|
|
848
|
+
finding.severity === "blocking")));
|
|
849
|
+
})) {
|
|
850
|
+
return "revise";
|
|
851
|
+
}
|
|
852
|
+
return "approved";
|
|
853
|
+
}
|
|
854
|
+
function assertResultDigest(raw, expected, runId) {
|
|
855
|
+
if (!expected) {
|
|
856
|
+
throw new Error(`supervisor plan review proof ${runId} is missing its result digest`);
|
|
857
|
+
}
|
|
858
|
+
const actual = digestResult(raw, "json");
|
|
859
|
+
if (actual.kind !== expected.kind ||
|
|
860
|
+
actual.sha256 !== expected.sha256 ||
|
|
861
|
+
actual.bytes !== expected.bytes) {
|
|
862
|
+
throw new Error(`supervisor plan review result does not match proof digest`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function workflowJournalRound(stage) {
|
|
866
|
+
if (typeof stage !== "string")
|
|
867
|
+
return undefined;
|
|
868
|
+
const review = /^Review round ([1-9][0-9]*)$/.exec(stage);
|
|
869
|
+
if (review)
|
|
870
|
+
return { kind: "review", round: Number(review[1]) };
|
|
871
|
+
const revision = /^Revise round ([1-9][0-9]*)$/.exec(stage);
|
|
872
|
+
if (revision)
|
|
873
|
+
return { kind: "revision", round: Number(revision[1]) };
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
function candidateDigest(candidate) {
|
|
877
|
+
return createHash("sha256")
|
|
878
|
+
.update(JSON.stringify(canonicalCandidate(candidate)))
|
|
879
|
+
.digest("hex");
|
|
880
|
+
}
|
|
881
|
+
function canonicalCandidate(candidate) {
|
|
882
|
+
return {
|
|
883
|
+
schema_version: candidate.schema_version,
|
|
884
|
+
plan_id: candidate.plan_id,
|
|
885
|
+
decision: candidate.decision,
|
|
886
|
+
rationale: candidate.rationale,
|
|
887
|
+
root: candidate.root,
|
|
888
|
+
work: candidate.work,
|
|
889
|
+
milestone: candidate.milestone,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function hasReviewRecoveryEvidence(coordRoot, goalId, planId) {
|
|
893
|
+
return (existsSync(reviewReceiptPath(coordRoot, goalId, planId)) ||
|
|
894
|
+
existsSync(join(coordRoot, ".harnery", "workflows", reviewWorkflowRunId(planId), "proof.json")));
|
|
895
|
+
}
|
|
896
|
+
function planProposalSchema(record) {
|
|
897
|
+
const policy = record.intent.replanning;
|
|
898
|
+
if (!policy)
|
|
899
|
+
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
900
|
+
return {
|
|
901
|
+
type: "object",
|
|
902
|
+
properties: {
|
|
903
|
+
decision: {
|
|
904
|
+
type: "string",
|
|
905
|
+
enum: record.intent.mission ? ["apply", "complete", "attention"] : ["apply", "attention"],
|
|
906
|
+
},
|
|
907
|
+
rationale: { type: "string", minLength: 1, maxLength: MAX_REASON },
|
|
908
|
+
root: { type: "string", maxLength: 32 },
|
|
909
|
+
work: {
|
|
910
|
+
type: "array",
|
|
911
|
+
maxItems: policy.max_work_items_per_plan,
|
|
912
|
+
items: {
|
|
913
|
+
type: "object",
|
|
914
|
+
properties: {
|
|
915
|
+
key: { type: "string", pattern: "^[a-z][a-z0-9-]{0,31}$", maxLength: 32 },
|
|
916
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
917
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
918
|
+
acceptance: {
|
|
919
|
+
type: "array",
|
|
920
|
+
maxItems: 50,
|
|
921
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
922
|
+
},
|
|
923
|
+
dependencies: {
|
|
924
|
+
type: "array",
|
|
925
|
+
maxItems: 50,
|
|
926
|
+
items: { type: "string", minLength: 1, maxLength: 100 },
|
|
927
|
+
},
|
|
928
|
+
template: { type: "string", enum: Object.keys(policy.templates), maxLength: 64 },
|
|
929
|
+
},
|
|
930
|
+
required: ["key", "title", "objective", "acceptance", "dependencies", "template"],
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
milestone: {
|
|
934
|
+
type: "object",
|
|
935
|
+
properties: {
|
|
936
|
+
sequence: { type: "number" },
|
|
937
|
+
title: { type: "string", minLength: 1, maxLength: 200 },
|
|
938
|
+
objective: { type: "string", minLength: 1, maxLength: 4_000 },
|
|
939
|
+
acceptance: {
|
|
940
|
+
type: "array",
|
|
941
|
+
minItems: 1,
|
|
942
|
+
maxItems: 50,
|
|
943
|
+
items: { type: "string", minLength: 1, maxLength: 500 },
|
|
944
|
+
},
|
|
945
|
+
},
|
|
946
|
+
required: ["sequence", "title", "objective", "acceptance"],
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
required: ["decision", "rationale", "root", "work"],
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
function normalizeProposal(planId, raw, record, previouslyApplied, trigger, preservedProposedAt) {
|
|
296
953
|
const policy = record.intent.replanning;
|
|
297
954
|
if (!policy)
|
|
298
955
|
throw new Error(`supervisor ${record.intent.id} does not allow replanning`);
|
|
@@ -300,14 +957,19 @@ function normalizeProposal(planId, raw, record, previouslyApplied) {
|
|
|
300
957
|
throw new Error("planner result must be an object");
|
|
301
958
|
}
|
|
302
959
|
const value = raw;
|
|
303
|
-
const
|
|
960
|
+
const proposedAt = preservedProposedAt ?? new Date().toISOString();
|
|
961
|
+
const decision = enumValue(value.decision, ["apply", "complete", "attention"], "plan decision");
|
|
304
962
|
const rationale = bounded(value.rationale, "plan rationale", MAX_REASON);
|
|
305
963
|
const root = typeof value.root === "string" ? value.root.trim() : "";
|
|
306
964
|
if (!Array.isArray(value.work))
|
|
307
965
|
throw new Error("plan work must be an array");
|
|
308
|
-
if (decision === "attention") {
|
|
309
|
-
if (root || value.work.length > 0)
|
|
310
|
-
throw new Error(
|
|
966
|
+
if (decision === "attention" || decision === "complete") {
|
|
967
|
+
if (root || value.work.length > 0 || value.milestone !== undefined) {
|
|
968
|
+
throw new Error(`${decision} plan must not contain work or milestone data`);
|
|
969
|
+
}
|
|
970
|
+
if (decision === "complete" && (!record.intent.mission || trigger !== "milestone")) {
|
|
971
|
+
throw new Error("mission completion may be proposed only at a milestone boundary");
|
|
972
|
+
}
|
|
311
973
|
return {
|
|
312
974
|
schema_version: SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
313
975
|
plan_id: planId,
|
|
@@ -315,9 +977,18 @@ function normalizeProposal(planId, raw, record, previouslyApplied) {
|
|
|
315
977
|
rationale,
|
|
316
978
|
root: "",
|
|
317
979
|
work: [],
|
|
318
|
-
proposed_at:
|
|
980
|
+
proposed_at: proposedAt,
|
|
319
981
|
};
|
|
320
982
|
}
|
|
983
|
+
const milestone = record.intent.mission
|
|
984
|
+
? normalizeMilestone(value.milestone, record.projection.milestones_completed + 1)
|
|
985
|
+
: undefined;
|
|
986
|
+
if (!record.intent.mission && value.milestone !== undefined) {
|
|
987
|
+
throw new Error("non-mission replacement plan must not contain milestone data");
|
|
988
|
+
}
|
|
989
|
+
if (milestone && milestone.sequence > record.intent.mission.max_milestones) {
|
|
990
|
+
throw new Error(`milestone ${milestone.sequence} exceeds mission limit ${record.intent.mission.max_milestones}`);
|
|
991
|
+
}
|
|
321
992
|
if (value.work.length < 1 || value.work.length > policy.max_work_items_per_plan) {
|
|
322
993
|
throw new Error(`plan work must contain 1 to ${policy.max_work_items_per_plan} items`);
|
|
323
994
|
}
|
|
@@ -393,9 +1064,49 @@ function normalizeProposal(planId, raw, record, previouslyApplied) {
|
|
|
393
1064
|
rationale,
|
|
394
1065
|
root,
|
|
395
1066
|
work,
|
|
396
|
-
|
|
1067
|
+
...(milestone ? { milestone } : {}),
|
|
1068
|
+
proposed_at: proposedAt,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function persistedProposalTimestamp(raw) {
|
|
1072
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1073
|
+
throw new Error("persisted review candidate must be an object");
|
|
1074
|
+
}
|
|
1075
|
+
const value = raw.proposed_at;
|
|
1076
|
+
if (typeof value !== "string" || value.length > 40 || !Number.isFinite(Date.parse(value))) {
|
|
1077
|
+
throw new Error("persisted review candidate has an invalid proposed_at timestamp");
|
|
1078
|
+
}
|
|
1079
|
+
return value;
|
|
1080
|
+
}
|
|
1081
|
+
function normalizeMilestone(raw, expectedSequence) {
|
|
1082
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1083
|
+
throw new Error("mission plan must contain a milestone object");
|
|
1084
|
+
}
|
|
1085
|
+
const value = raw;
|
|
1086
|
+
if (value.sequence !== expectedSequence) {
|
|
1087
|
+
throw new Error(`milestone sequence must be ${expectedSequence}`);
|
|
1088
|
+
}
|
|
1089
|
+
if (!Array.isArray(value.acceptance) || value.acceptance.length < 1) {
|
|
1090
|
+
throw new Error("milestone acceptance must be a non-empty array");
|
|
1091
|
+
}
|
|
1092
|
+
if (value.acceptance.length > 50)
|
|
1093
|
+
throw new Error("milestone acceptance exceeds 50 criteria");
|
|
1094
|
+
return {
|
|
1095
|
+
sequence: expectedSequence,
|
|
1096
|
+
title: bounded(value.title, "milestone title", 200),
|
|
1097
|
+
objective: bounded(value.objective, "milestone objective", 4_000),
|
|
1098
|
+
acceptance: value.acceptance.map((criterion, index) => bounded(criterion, `milestone acceptance[${index}]`, 500)),
|
|
397
1099
|
};
|
|
398
1100
|
}
|
|
1101
|
+
function planTrigger(record) {
|
|
1102
|
+
if (record.intent.mission &&
|
|
1103
|
+
record.projection.plan_generation === 0 &&
|
|
1104
|
+
record.work.length === 0) {
|
|
1105
|
+
return "initial";
|
|
1106
|
+
}
|
|
1107
|
+
const root = record.work.find((work) => work.intent.id === record.projection.root_work_id);
|
|
1108
|
+
return record.intent.mission && root?.projection.state === "succeeded" ? "milestone" : "recovery";
|
|
1109
|
+
}
|
|
399
1110
|
function appendPlanEvent(coordRootRaw, goalId, planId, input) {
|
|
400
1111
|
const coordRoot = resolve(coordRootRaw);
|
|
401
1112
|
const path = join(planDir(coordRoot, goalId, planId), "events.jsonl");
|
|
@@ -417,6 +1128,12 @@ function appendPlanEvent(coordRootRaw, goalId, planId, input) {
|
|
|
417
1128
|
appendFileSync(path, line, { encoding: "utf8", mode: 0o600 });
|
|
418
1129
|
chmodSync(path, 0o600);
|
|
419
1130
|
}
|
|
1131
|
+
function appendPlanEventIfMissing(coordRoot, goalId, planId, eventType, input) {
|
|
1132
|
+
const plan = readSupervisorPlan(coordRoot, goalId, planId);
|
|
1133
|
+
if (plan.events.some((event) => event.event === eventType))
|
|
1134
|
+
return;
|
|
1135
|
+
appendPlanEvent(coordRoot, goalId, planId, input);
|
|
1136
|
+
}
|
|
420
1137
|
function outcome(plan) {
|
|
421
1138
|
return {
|
|
422
1139
|
plan_id: plan.request.id,
|
|
@@ -468,6 +1185,31 @@ function planScriptPath(coordRoot, goalId, planId) {
|
|
|
468
1185
|
function planProposalPath(coordRoot, goalId, planId) {
|
|
469
1186
|
return join(planDir(coordRoot, goalId, planId), "proposal.json");
|
|
470
1187
|
}
|
|
1188
|
+
function reviewScriptPath(coordRoot, goalId, planId) {
|
|
1189
|
+
return join(planDir(coordRoot, goalId, planId), "review.mjs");
|
|
1190
|
+
}
|
|
1191
|
+
function reviewInputPath(coordRoot, goalId, planId) {
|
|
1192
|
+
return join(planDir(coordRoot, goalId, planId), "review-input.json");
|
|
1193
|
+
}
|
|
1194
|
+
function reviewReceiptPath(coordRoot, goalId, planId) {
|
|
1195
|
+
return join(planDir(coordRoot, goalId, planId), "review.json");
|
|
1196
|
+
}
|
|
1197
|
+
function reviewWorkflowRunId(planId) {
|
|
1198
|
+
return `${planId}-review`;
|
|
1199
|
+
}
|
|
1200
|
+
function readBoundedJson(path, label) {
|
|
1201
|
+
if (!existsSync(path))
|
|
1202
|
+
throw new Error(`${label} does not exist at ${path}`);
|
|
1203
|
+
const size = statSync(path).size;
|
|
1204
|
+
if (size <= 0 || size > MAX_RECORD_BYTES)
|
|
1205
|
+
throw new Error(`${label} has invalid size ${size}`);
|
|
1206
|
+
try {
|
|
1207
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1208
|
+
}
|
|
1209
|
+
catch (error) {
|
|
1210
|
+
throw new Error(`cannot parse ${label} at ${path}: ${error.message}`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
471
1213
|
function bounded(value, field, max) {
|
|
472
1214
|
if (typeof value !== "string")
|
|
473
1215
|
throw new Error(`${field} must be a string`);
|
|
@@ -478,6 +1220,12 @@ function bounded(value, field, max) {
|
|
|
478
1220
|
throw new Error(`${field} exceeds ${max} characters`);
|
|
479
1221
|
return normalized;
|
|
480
1222
|
}
|
|
1223
|
+
function positive(value, field, max) {
|
|
1224
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
1225
|
+
throw new Error(`${field} must be an integer from 1 to ${max}`);
|
|
1226
|
+
}
|
|
1227
|
+
return value;
|
|
1228
|
+
}
|
|
481
1229
|
function enumValue(value, values, field) {
|
|
482
1230
|
if (typeof value !== "string" || !values.includes(value)) {
|
|
483
1231
|
throw new Error(`${field} must be one of ${values.join(", ")}`);
|