harnery 0.18.0 → 0.19.0

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