harnery 0.18.0 → 0.19.1

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