harnery 0.17.0 → 0.19.0

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