@navels/neal 0.1.0 → 0.3.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 (120) hide show
  1. package/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +264 -24
  7. package/dist/neal/agents/prompts.js +7 -15
  8. package/dist/neal/agents/rounds.js +51 -73
  9. package/dist/neal/agents/schemas.js +615 -502
  10. package/dist/neal/agents/structured-json.js +36 -0
  11. package/dist/neal/blocked-guidance.js +16 -9
  12. package/dist/neal/cli.js +2 -2
  13. package/dist/neal/commands/check.js +2 -2
  14. package/dist/neal/commands/compat.js +100 -36
  15. package/dist/neal/commands/interactive-activity.js +2 -2
  16. package/dist/neal/commands/recovery-guidance.js +20 -8
  17. package/dist/neal/commands/runtime.js +4 -2
  18. package/dist/neal/config.js +40 -7
  19. package/dist/neal/context/inline-review-context.js +13 -28
  20. package/dist/neal/context/reviewer-context.js +19 -14
  21. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  22. package/dist/neal/eval/reviewer-eval.js +155 -0
  23. package/dist/neal/git.js +9 -3
  24. package/dist/neal/interactive-controls.js +5 -0
  25. package/dist/neal/orchestrator/completion.js +215 -113
  26. package/dist/neal/orchestrator/failures.js +7 -0
  27. package/dist/neal/orchestrator/notifications.js +9 -9
  28. package/dist/neal/orchestrator/phases/planning.js +124 -112
  29. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  30. package/dist/neal/orchestrator/phases/review.js +28 -1
  31. package/dist/neal/orchestrator/split-plan.js +12 -11
  32. package/dist/neal/orchestrator/transitions.js +36 -71
  33. package/dist/neal/orchestrator.js +3 -0
  34. package/dist/neal/plan-doc.js +24 -1
  35. package/dist/neal/plan-queue.js +29 -1
  36. package/dist/neal/prompts/assert-builder.js +8 -1
  37. package/dist/neal/prompts/execute.js +11 -19
  38. package/dist/neal/prompts/planning.js +64 -55
  39. package/dist/neal/prompts/review-doctrine.js +13 -50
  40. package/dist/neal/prompts/specialized.js +28 -22
  41. package/dist/neal/prompts/specs.js +223 -17
  42. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  43. package/dist/neal/providers/anthropic-claude.js +309 -248
  44. package/dist/neal/providers/detection.js +3 -7
  45. package/dist/neal/providers/git-config-isolation.js +83 -0
  46. package/dist/neal/providers/openai-codex.js +102 -204
  47. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  48. package/dist/neal/providers/openai-compatible.js +1079 -403
  49. package/dist/neal/providers/openrouter-routing.js +68 -0
  50. package/dist/neal/providers/pricing.js +124 -0
  51. package/dist/neal/providers/rate-card.js +2275 -0
  52. package/dist/neal/providers/registry.js +6 -17
  53. package/dist/neal/providers/telemetry.js +4 -0
  54. package/dist/neal/providers/types.js +12 -0
  55. package/dist/neal/resume-decision.js +7 -6
  56. package/dist/neal/resume-planner.js +9 -0
  57. package/dist/neal/retrospective.js +36 -7
  58. package/dist/neal/review-debt.js +30 -0
  59. package/dist/neal/review-findings/provider.js +17 -29
  60. package/dist/neal/review.js +17 -1
  61. package/dist/neal/run-metrics.js +74 -9
  62. package/dist/neal/state-invariants.js +11 -3
  63. package/dist/neal/state-views.js +27 -5
  64. package/dist/neal/state.js +59 -15
  65. package/dist/neal/status.js +28 -1
  66. package/dist/neal/support.js +3 -3
  67. package/docs/README.md +22 -18
  68. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  69. package/docs/architecture.md +79 -83
  70. package/docs/automation.md +11 -11
  71. package/docs/compat.md +91 -61
  72. package/docs/compatible-models.md +145 -110
  73. package/docs/demo.md +15 -5
  74. package/docs/maintenance.md +74 -32
  75. package/docs/plan-format.md +61 -33
  76. package/docs/prompt-evals.md +96 -0
  77. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  78. package/docs/providers.md +330 -283
  79. package/docs/release.md +43 -40
  80. package/docs/review-convergence.md +246 -0
  81. package/docs/state-machine.md +61 -59
  82. package/docs/storage.md +40 -31
  83. package/docs/troubleshooting.md +32 -29
  84. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  85. package/examples/compat/add-edit-verify/broken.diff +2 -2
  86. package/examples/compat/add-edit-verify/good.diff +2 -2
  87. package/examples/compat/add-edit-verify/src/add.js +1 -1
  88. package/examples/compat/manifest.json +25 -29
  89. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  90. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  91. package/examples/compat/sum-grep-edit/good.diff +12 -0
  92. package/examples/compat/sum-grep-edit/package.json +5 -0
  93. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  94. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  95. package/examples/issue-triage-js/README.md +0 -15
  96. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  97. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  98. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  99. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  100. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  101. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  102. package/examples/reviewer-eval/manifest.json +45 -0
  103. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  104. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  105. package/{config.yml → neal.yml} +1 -1
  106. package/package.json +20 -15
  107. package/dist/neal/providers/generic-agentic.js +0 -1238
  108. package/docs/comparison.md +0 -105
  109. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  110. package/examples/compat/is-even-add-test/broken.diff +0 -11
  111. package/examples/compat/is-even-add-test/good.diff +0 -11
  112. package/examples/compat/is-even-add-test/package.json +0 -5
  113. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  114. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  115. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  116. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  117. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  118. package/examples/compat/reverse-grep-edit/package.json +0 -5
  119. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  120. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,405 +1,568 @@
1
+ import { z } from 'zod';
2
+ import { normalizeExecutionShapeDeclaration, validatePlanDocument } from '../plan-validation.js';
1
3
  import { repairReviewerSquashMessageDraft, validateReviewerSquashMessageDraft } from '../squash-message.js';
2
- const REVIEWER_PAYLOAD_KEYS = [
3
- 'summary',
4
- 'findings',
5
- 'meaningfulProgressAction',
6
- 'meaningfulProgressRationale',
7
- ];
8
- const REVIEWER_FINDING_KEYS = ['severity', 'files', 'claim', 'evidence', 'requiredAction'];
4
+ export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_START = 'NEAL_PROGRESS_JUSTIFICATION_JSON_START';
5
+ export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_END = 'NEAL_PROGRESS_JUSTIFICATION_JSON_END';
6
+ // --- zod payload definitions (single source of truth) -----------------------
9
7
  const REVIEWER_FINDING_SEVERITIES = ['blocking', 'non_blocking'];
8
+ export const PLAN_REVIEWER_FINDING_CLASSES = [
9
+ 'plan_correctness',
10
+ 'verification_hardening',
11
+ ];
10
12
  const REVIEWER_MEANINGFUL_PROGRESS_ACTIONS = [
11
13
  'accept',
12
14
  'block_for_operator',
13
15
  'replace_plan',
14
16
  'advance_parent',
15
17
  ];
16
- export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_START = 'NEAL_PROGRESS_JUSTIFICATION_JSON_START';
17
- export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_END = 'NEAL_PROGRESS_JUSTIFICATION_JSON_END';
18
+ const EXECUTION_SHAPES = ['one_shot', 'multi_scope', 'multi_scope_unknown'];
19
+ const CONSULTANT_TRIAGE_CATEGORIES = [
20
+ 'misunderstanding',
21
+ 'authorization',
22
+ 'external_precondition',
23
+ 'impossible_task',
24
+ ];
25
+ const CODER_FINDING_RESPONSE_DECISIONS = ['fixed', 'rejected', 'deferred'];
26
+ const CODER_RESPONSE_OUTCOMES = ['responded', 'blocked', 'split_plan'];
27
+ const CODER_BLOCKED_RECOVERY_ACTIONS = [
28
+ 'resume_current_scope',
29
+ 'replace_current_scope',
30
+ 'stay_blocked',
31
+ 'terminal_block',
32
+ ];
33
+ const CODER_PLAN_RESPONSE_OUTCOMES = ['responded', 'blocked'];
34
+ const CODER_PLAN_ACTIONS = ['ready_for_review', 'blocked'];
35
+ const CODER_SCOPE_ACTIONS = ['continue', 'scope_done', 'done', 'blocked', 'split_plan', 'manual_gate'];
36
+ const MANUAL_GATE_RESUME_CHECK_TYPES = ['command'];
37
+ const MANUAL_GATE_RESUME_CHECK_CWDS = ['repo', 'run_dir'];
38
+ const FINAL_COMPLETION_REVIEWER_ACTIONS = ['accept_complete', 'continue_execution', 'block_for_operator'];
39
+ const SQUASH_COMMIT_MESSAGE_DESCRIPTION = 'Project-facing Git history metadata used only when action is accept_complete; set null for continue_execution or block_for_operator.';
40
+ const SQUASH_COMMIT_SUBJECT_DESCRIPTION = 'Concise project-facing commit subject summarizing code or product behavior, not plan documents, paths, scopes, Neal mechanics, provider process, reviewer process, or final cleanup.';
41
+ const SQUASH_COMMIT_BULLETS_DESCRIPTION = 'Two to five project-facing Git history bullets summarizing behavior changes; avoid plan paths, markdown plan filenames, temporary run paths, scope wording, Neal mechanics, provider process, or reviewer process.';
42
+ // The reviewer payload is the strict unknown-key family: unknown properties
43
+ // are rejected at both the top level and inside findings (z.strictObject).
44
+ // Every other payload uses zod's default strip-mode z.object — unknown
45
+ // properties are accepted and omitted from the normalized output.
46
+ const reviewerFindingSchema = z.strictObject({
47
+ severity: z.enum(REVIEWER_FINDING_SEVERITIES),
48
+ files: z.array(z.string()),
49
+ claim: z.string(),
50
+ evidence: z.string(),
51
+ requiredAction: z.string(),
52
+ });
53
+ const reviewerPayloadSchema = z.strictObject({
54
+ summary: z.string(),
55
+ findings: z.array(reviewerFindingSchema),
56
+ meaningfulProgressAction: z.enum(REVIEWER_MEANINGFUL_PROGRESS_ACTIONS),
57
+ meaningfulProgressRationale: z.string(),
58
+ });
59
+ const planReviewerFindingSchema = z.object({
60
+ severity: z.enum(REVIEWER_FINDING_SEVERITIES),
61
+ files: z.array(z.string()),
62
+ claim: z.string(),
63
+ requiredAction: z.string(),
64
+ // Optional at the payload boundary: an absent class normalizes to the fail-safe
65
+ // plan_correctness downstream (see validatePlanReviewerPayload), while a
66
+ // present-but-invalid value is rejected by the sequential validator before any
67
+ // normalization runs. The reviewer round is a `neal-json-block-v1` structured
68
+ // round (the schema is advisory prompt context, not native strict output), so —
69
+ // like the consultant's optional targetCanonicalIds — this property is
70
+ // intentionally absent from the emitted `required` tuple and buildPlanReviewerSchema
71
+ // is excluded from the strict all-required contract.
72
+ findingClass: z.enum(PLAN_REVIEWER_FINDING_CLASSES).optional(),
73
+ });
74
+ const planReviewerPayloadSchema = z.object({
75
+ summary: z.string(),
76
+ executionShape: z.enum(EXECUTION_SHAPES),
77
+ findings: z.array(planReviewerFindingSchema),
78
+ });
79
+ const consultantPayloadSchema = z.object({
80
+ recoverable: z.boolean(),
81
+ triageCategory: z.enum(CONSULTANT_TRIAGE_CATEGORIES),
82
+ resolutionDirective: z.string(),
83
+ targetCanonicalIds: z.array(z.string()).optional(),
84
+ rationale: z.string(),
85
+ });
86
+ const coderFindingResponseSchema = z.object({
87
+ id: z.string(),
88
+ decision: z.enum(CODER_FINDING_RESPONSE_DECISIONS),
89
+ summary: z.string(),
90
+ });
91
+ const coderResponsePayloadSchema = z.object({
92
+ outcome: z.enum(CODER_RESPONSE_OUTCOMES),
93
+ summary: z.string(),
94
+ blocker: z.string(),
95
+ derivedPlan: z.string(),
96
+ responses: z.array(coderFindingResponseSchema),
97
+ });
98
+ const coderBlockedRecoveryDispositionPayloadSchema = z.object({
99
+ action: z.enum(CODER_BLOCKED_RECOVERY_ACTIONS),
100
+ summary: z.string(),
101
+ rationale: z.string(),
102
+ blocker: z.string(),
103
+ replacementPlan: z.string(),
104
+ });
105
+ const coderPlanResponsePayloadSchema = z.object({
106
+ outcome: z.enum(CODER_PLAN_RESPONSE_OUTCOMES),
107
+ summary: z.string(),
108
+ blocker: z.string(),
109
+ responses: z.array(coderFindingResponseSchema),
110
+ });
111
+ const coderPlanPayloadSchema = z.object({
112
+ action: z.enum(CODER_PLAN_ACTIONS),
113
+ message: z.string(),
114
+ executionShape: z.enum(EXECUTION_SHAPES),
115
+ planBody: z.string(),
116
+ blockedReason: z.string(),
117
+ });
118
+ const executeScopeProgressSchema = z.object({
119
+ milestoneTargeted: z.string(),
120
+ newEvidence: z.string(),
121
+ whyNotRedundant: z.string(),
122
+ nextStepUnlocked: z.string(),
123
+ });
124
+ const manualGateResumeCheckSchema = z.object({
125
+ type: z.enum(MANUAL_GATE_RESUME_CHECK_TYPES),
126
+ name: z.string(),
127
+ command: z.array(z.string()),
128
+ cwd: z.enum(MANUAL_GATE_RESUME_CHECK_CWDS).nullable(),
129
+ timeoutMs: z.number().nullable(),
130
+ });
131
+ const manualGateSchema = z.object({
132
+ id: z.string(),
133
+ title: z.string(),
134
+ reason: z.string(),
135
+ instructionsMarkdown: z.string(),
136
+ resumeChecks: z.array(manualGateResumeCheckSchema),
137
+ });
138
+ const coderScopePayloadSchema = z.object({
139
+ action: z.enum(CODER_SCOPE_ACTIONS),
140
+ message: z.string(),
141
+ progress: executeScopeProgressSchema,
142
+ manualGate: manualGateSchema.nullable(),
143
+ derivedPlan: z.string(),
144
+ blockedReason: z.string(),
145
+ });
146
+ // Parse-stage view of the coder scope payload. The full definition above is
147
+ // what the emitted JSON Schema promises callers; validation historically only
148
+ // checks object-ness of `progress` and `manualGate` up front, then applies
149
+ // the delimiter-protocol progress messages and the manual-gate deep
150
+ // validation (which trims strings and accepts absent cwd/timeoutMs) as
151
+ // explicit post-parse steps with their own pinned message grammar.
152
+ const coderScopeParseSchema = coderScopePayloadSchema.extend({
153
+ progress: z.looseObject({}),
154
+ manualGate: z.looseObject({}).nullable(),
155
+ });
156
+ const finalCompletionSummaryPayloadSchema = z.object({
157
+ planGoalSatisfied: z.boolean(),
158
+ whatChangedOverall: z.string(),
159
+ verificationSummary: z.string(),
160
+ remainingKnownGaps: z.array(z.string()),
161
+ });
162
+ const squashCommitMessageDraftSchema = z.object({
163
+ subject: z.string().describe(SQUASH_COMMIT_SUBJECT_DESCRIPTION),
164
+ bullets: z.array(z.string()).min(2).max(5).describe(SQUASH_COMMIT_BULLETS_DESCRIPTION),
165
+ });
166
+ const finalCompletionReviewerPayloadSchema = z.object({
167
+ action: z.enum(FINAL_COMPLETION_REVIEWER_ACTIONS),
168
+ summary: z.string(),
169
+ rationale: z.string(),
170
+ missingWork: z
171
+ .object({
172
+ summary: z.string(),
173
+ requiredOutcome: z.string(),
174
+ verification: z.string(),
175
+ })
176
+ .nullable(),
177
+ squashCommitMessage: squashCommitMessageDraftSchema.nullable().describe(SQUASH_COMMIT_MESSAGE_DESCRIPTION),
178
+ });
179
+ // Parse-stage view of the final-completion reviewer verdict. The squash draft
180
+ // is advisory: a malformed or absent draft must never reject the verdict, so
181
+ // validation only checks object-ness here and routes the draft through
182
+ // validate/repair in parseFinalCompletionReviewerPayload.
183
+ const finalCompletionReviewerParseSchema = finalCompletionReviewerPayloadSchema.extend({
184
+ squashCommitMessage: z.looseObject({}).nullable().optional(),
185
+ });
186
+ // Canonical key order of the previous hand-written builders. Emitted schemas
187
+ // are serialized into prompt text with JSON.stringify(schema, null, 2), so
188
+ // this ordering changes prompt bytes and is pinned byte-for-byte by
189
+ // test/agent-payload-schemas.test.ts.
190
+ const EMITTED_SCHEMA_KEY_ORDER = [
191
+ 'description',
192
+ 'type',
193
+ 'enum',
194
+ 'minItems',
195
+ 'maxItems',
196
+ 'items',
197
+ 'properties',
198
+ 'required',
199
+ 'additionalProperties',
200
+ ];
201
+ function isJsonSchemaRecord(value) {
202
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
203
+ }
204
+ // Collapse zod's `anyOf: [X, { type: 'null' }]` encoding of .nullable() into
205
+ // the hand-written builders' merged form: X with `type: [<X type>, 'null']`,
206
+ // appending null to X's enum when one is present (e.g. the resume-check cwd).
207
+ function collapseNullableAnyOf(node) {
208
+ const { anyOf, ...rest } = node;
209
+ if (!Array.isArray(anyOf) || anyOf.length !== 2) {
210
+ return node;
211
+ }
212
+ const [base, nullBranch] = anyOf;
213
+ if (!isJsonSchemaRecord(base) || !isJsonSchemaRecord(nullBranch)) {
214
+ return node;
215
+ }
216
+ if (nullBranch.type !== 'null' || Object.keys(nullBranch).length !== 1) {
217
+ return node;
218
+ }
219
+ const merged = { ...base, ...rest };
220
+ merged.type = Array.isArray(base.type) ? [...base.type, 'null'] : [base.type, 'null'];
221
+ if (Array.isArray(base.enum)) {
222
+ merged.enum = [...base.enum, null];
223
+ }
224
+ return merged;
225
+ }
226
+ function normalizeEmittedSchemaNode(node) {
227
+ const record = collapseNullableAnyOf(node);
228
+ const keys = Object.keys(record);
229
+ const orderedKeys = [
230
+ ...EMITTED_SCHEMA_KEY_ORDER.filter((key) => keys.includes(key)),
231
+ ...keys.filter((key) => !EMITTED_SCHEMA_KEY_ORDER.includes(key)),
232
+ ];
233
+ const normalized = {};
234
+ for (const key of orderedKeys) {
235
+ const value = record[key];
236
+ if (key === 'items' && isJsonSchemaRecord(value)) {
237
+ normalized[key] = normalizeEmittedSchemaNode(value);
238
+ }
239
+ else if (key === 'properties' && isJsonSchemaRecord(value)) {
240
+ const properties = {};
241
+ for (const [propertyName, propertySchema] of Object.entries(value)) {
242
+ properties[propertyName] = isJsonSchemaRecord(propertySchema)
243
+ ? normalizeEmittedSchemaNode(propertySchema)
244
+ : propertySchema;
245
+ }
246
+ normalized[key] = properties;
247
+ }
248
+ else {
249
+ normalized[key] = value;
250
+ }
251
+ }
252
+ const type = normalized.type;
253
+ const isObjectNode = type === 'object' || (Array.isArray(type) && type.includes('object'));
254
+ if (isObjectNode && normalized.properties !== undefined) {
255
+ normalized.additionalProperties = false;
256
+ }
257
+ return normalized;
258
+ }
259
+ // The declared literal return shape is asserted here rather than proven
260
+ // structurally: the byte-level serialization pins in
261
+ // test/agent-payload-schemas.test.ts fail whenever the zod-derived emission
262
+ // diverges from the declared shapes.
263
+ function emitJsonSchema(schema) {
264
+ const emitted = z.toJSONSchema(schema);
265
+ delete emitted.$schema;
266
+ return normalizeEmittedSchemaNode(emitted);
267
+ }
18
268
  export function buildReviewerSchema() {
19
- return {
20
- type: 'object',
21
- properties: {
22
- summary: { type: 'string' },
23
- findings: {
24
- type: 'array',
25
- items: {
26
- type: 'object',
27
- properties: {
28
- severity: { type: 'string', enum: ['blocking', 'non_blocking'] },
29
- files: { type: 'array', items: { type: 'string' } },
30
- claim: { type: 'string' },
31
- evidence: { type: 'string' },
32
- requiredAction: { type: 'string' },
33
- },
34
- required: ['severity', 'files', 'claim', 'evidence', 'requiredAction'],
35
- additionalProperties: false,
36
- },
37
- },
38
- meaningfulProgressAction: { type: 'string', enum: ['accept', 'block_for_operator', 'replace_plan', 'advance_parent'] },
39
- meaningfulProgressRationale: { type: 'string' },
40
- },
41
- required: ['summary', 'findings', 'meaningfulProgressAction', 'meaningfulProgressRationale'],
42
- additionalProperties: false,
43
- };
269
+ return emitJsonSchema(reviewerPayloadSchema);
44
270
  }
45
- export function validateReviewerPayload(payload) {
46
- const value = requireReviewerObject(payload, 'Reviewer payload');
47
- requireReviewerProperties(value, REVIEWER_PAYLOAD_KEYS, 'Reviewer payload');
48
- if (!Array.isArray(value.findings)) {
49
- throw new Error('Reviewer payload.findings must be an array.');
271
+ export function buildPlanReviewerSchema() {
272
+ return emitJsonSchema(planReviewerPayloadSchema);
273
+ }
274
+ export function buildConsultantSchema() {
275
+ return emitJsonSchema(consultantPayloadSchema);
276
+ }
277
+ export function buildCoderResponseSchema() {
278
+ return emitJsonSchema(coderResponsePayloadSchema);
279
+ }
280
+ export function buildCoderBlockedRecoveryDispositionSchema() {
281
+ return emitJsonSchema(coderBlockedRecoveryDispositionPayloadSchema);
282
+ }
283
+ export function buildCoderPlanResponseSchema() {
284
+ return emitJsonSchema(coderPlanResponsePayloadSchema);
285
+ }
286
+ export function buildCoderPlanSchema() {
287
+ return emitJsonSchema(coderPlanPayloadSchema);
288
+ }
289
+ export function buildCoderScopeSchema() {
290
+ return emitJsonSchema(coderScopePayloadSchema);
291
+ }
292
+ export function buildExecuteScopeProgressSchema() {
293
+ return emitJsonSchema(executeScopeProgressSchema);
294
+ }
295
+ export function buildFinalCompletionSummarySchema() {
296
+ return emitJsonSchema(finalCompletionSummaryPayloadSchema);
297
+ }
298
+ export function buildFinalCompletionReviewerSchema() {
299
+ return emitJsonSchema(finalCompletionReviewerPayloadSchema);
300
+ }
301
+ // --- sequential zod-backed validation ------------------------------------------
302
+ //
303
+ // The historical validators were strictly sequential: each field was read and
304
+ // checked one at a time in a fixed order, throwing at the FIRST failure, so a
305
+ // property later in the validation order was never read once an earlier check
306
+ // failed — a later throwing accessor could not mask an earlier error. A
307
+ // whole-payload safeParse instead reads every field eagerly to collect all
308
+ // issues. The traversal below therefore keeps the historical sequential read
309
+ // order and short-circuit semantics while zod performs the actual
310
+ // validation: each field or array element is parsed by its own zod
311
+ // sub-schema at the point the traversal reaches it, and the first zod issue
312
+ // is translated through ONE shared formatter into the historical grammar:
313
+ // `<Label>[.<path>] must be a non-null object.` / `... must be a string.` /
314
+ // `... must be a boolean.` / `... must be an array.` /
315
+ // `... must be exactly one of: a, b.`
316
+ function describeSchemaIssue(fieldPath, issue) {
317
+ if (issue.code === 'invalid_type') {
318
+ switch (issue.expected) {
319
+ case 'object':
320
+ return `${fieldPath} must be a non-null object.`;
321
+ case 'string':
322
+ return `${fieldPath} must be a string.`;
323
+ case 'boolean':
324
+ return `${fieldPath} must be a boolean.`;
325
+ case 'array':
326
+ return `${fieldPath} must be an array.`;
327
+ default:
328
+ break;
329
+ }
50
330
  }
51
- return {
52
- summary: requireReviewerString(value.summary, 'Reviewer payload.summary'),
53
- findings: value.findings.map(validateReviewerFindingPayload),
54
- meaningfulProgressAction: requireReviewerEnum(value.meaningfulProgressAction, 'Reviewer payload.meaningfulProgressAction', REVIEWER_MEANINGFUL_PROGRESS_ACTIONS),
55
- meaningfulProgressRationale: requireReviewerString(value.meaningfulProgressRationale, 'Reviewer payload.meaningfulProgressRationale'),
56
- };
331
+ if (issue.code === 'invalid_value') {
332
+ return `${fieldPath} must be exactly one of: ${issue.values.join(', ')}.`;
333
+ }
334
+ // Any other zod constraint surfaces with zod's own issue description.
335
+ return `${fieldPath} failed validation: ${issue.message}`;
57
336
  }
58
- function validateReviewerFindingPayload(finding, index) {
59
- const label = `Reviewer payload.findings[${index}]`;
60
- const value = requireReviewerObject(finding, label);
61
- requireReviewerProperties(value, REVIEWER_FINDING_KEYS, label);
62
- if (!Array.isArray(value.files)) {
63
- throw new Error(`${label}.files must be an array.`);
337
+ function firstSchemaIssueMessage(schema, value, fieldPath) {
338
+ const result = schema.safeParse(value);
339
+ if (result.success) {
340
+ // Unreachable: callers invoke this only for values the schema rejects.
341
+ return `${fieldPath} failed validation.`;
64
342
  }
65
- return {
66
- severity: requireReviewerEnum(value.severity, `${label}.severity`, REVIEWER_FINDING_SEVERITIES),
67
- files: value.files.map((file, fileIndex) => requireReviewerString(file, `${label}.files[${fileIndex}]`)),
68
- claim: requireReviewerString(value.claim, `${label}.claim`),
69
- evidence: requireReviewerString(value.evidence, `${label}.evidence`),
70
- requiredAction: requireReviewerString(value.requiredAction, `${label}.requiredAction`),
71
- };
343
+ return describeSchemaIssue(fieldPath, result.error.issues[0]);
344
+ }
345
+ // Container gates: return the value when it has the container type the
346
+ // schema demands; otherwise derive the failure message from the schema's own
347
+ // zod issue. zod fails fast on container-type mismatches without reading any
348
+ // property or element, so no accessor is invoked for a rejected value, and
349
+ // the gate lets the traversal defer member reads to their historical
350
+ // positions instead of letting a whole-container parse read them eagerly.
351
+ function requireRecordSequential(schema, value, fieldPath) {
352
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
353
+ return value;
354
+ }
355
+ throw new Error(firstSchemaIssueMessage(schema, value, fieldPath));
72
356
  }
73
- function requireReviewerObject(value, label) {
74
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
75
- throw new Error(`${label} must be a non-null object.`);
357
+ function requireArraySequential(schema, value, fieldPath) {
358
+ if (Array.isArray(value)) {
359
+ return value;
76
360
  }
77
- return value;
361
+ throw new Error(firstSchemaIssueMessage(schema, value, fieldPath));
78
362
  }
79
- function requireReviewerProperties(value, requiredKeys, label) {
80
- for (const key of requiredKeys) {
81
- if (!Object.prototype.hasOwnProperty.call(value, key)) {
363
+ function validateFieldSequential(fieldSchema, value, fieldPath) {
364
+ if (fieldSchema instanceof z.ZodOptional) {
365
+ if (value === undefined) {
366
+ return undefined;
367
+ }
368
+ return validateFieldSequential(fieldSchema.unwrap(), value, fieldPath);
369
+ }
370
+ if (fieldSchema instanceof z.ZodNullable) {
371
+ if (value === null) {
372
+ return null;
373
+ }
374
+ return validateFieldSequential(fieldSchema.unwrap(), value, fieldPath);
375
+ }
376
+ if (fieldSchema instanceof z.ZodArray) {
377
+ const items = requireArraySequential(fieldSchema, value, fieldPath);
378
+ const element = fieldSchema.element;
379
+ if (element instanceof z.ZodObject) {
380
+ // Historical object-array semantics: every item's object-ness is
381
+ // checked before any item's fields are read.
382
+ const records = items.map((item, index) => requireRecordSequential(element, item, `${fieldPath}[${index}]`));
383
+ return records.map((item, index) => walkObjectSequential(element, item, `${fieldPath}[${index}]`));
384
+ }
385
+ return items.map((item, index) => validateFieldSequential(element, item, `${fieldPath}[${index}]`));
386
+ }
387
+ if (fieldSchema instanceof z.ZodObject) {
388
+ const record = requireRecordSequential(fieldSchema, value, fieldPath);
389
+ // Empty-shape loose objects are the historical object-ness-only
390
+ // passthroughs (coder-scope progress/manualGate, the squash draft): the
391
+ // ORIGINAL reference is returned so the explicit post-parse steps read
392
+ // fields at their historical positions.
393
+ if (Object.keys(fieldSchema.shape).length === 0) {
394
+ return record;
395
+ }
396
+ return walkObjectSequential(fieldSchema, record, fieldPath);
397
+ }
398
+ // Leaf schema: zod itself enforces the constraint at the point the
399
+ // traversal reaches this field; its first issue is translated through the
400
+ // shared formatter.
401
+ const result = fieldSchema.safeParse(value);
402
+ if (!result.success) {
403
+ throw new Error(describeSchemaIssue(fieldPath, result.error.issues[0]));
404
+ }
405
+ return result.data;
406
+ }
407
+ // Walks a strip-mode object schema field-by-field in shape declaration order,
408
+ // reading each property only when its turn comes and throwing at the first
409
+ // failure. Unknown keys are omitted from the rebuilt output (historical
410
+ // permissive behavior).
411
+ function walkObjectSequential(schema, record, basePath) {
412
+ const output = {};
413
+ for (const [key, fieldSchema] of Object.entries(schema.shape)) {
414
+ const fieldPath = `${basePath}.${key}`;
415
+ // Historical optional/nullable field patterns performed one property
416
+ // read for the undefined/null test and a FRESH read for validation when
417
+ // the field was present, so stateful accessors observe both reads (the
418
+ // consultant targetCanonicalIds ternary and the coder-scope
419
+ // manualGate ternary behaved this way).
420
+ if (fieldSchema instanceof z.ZodOptional) {
421
+ if (record[key] === undefined) {
422
+ continue;
423
+ }
424
+ output[key] = validateFieldSequential(fieldSchema.unwrap(), record[key], fieldPath);
425
+ continue;
426
+ }
427
+ if (fieldSchema instanceof z.ZodNullable) {
428
+ if (record[key] === null) {
429
+ output[key] = null;
430
+ continue;
431
+ }
432
+ output[key] = validateFieldSequential(fieldSchema.unwrap(), record[key], fieldPath);
433
+ continue;
434
+ }
435
+ output[key] = validateFieldSequential(fieldSchema, record[key], fieldPath);
436
+ }
437
+ return output;
438
+ }
439
+ // Permissive-family entry point: object-ness first (`<Label> must be a
440
+ // non-null object.`), then the sequential field walk in shape declaration
441
+ // order, which matches the historical field-by-field check order of these
442
+ // validators.
443
+ function parsePayload(schema, payload, label) {
444
+ if (!(schema instanceof z.ZodObject)) {
445
+ throw new Error(`${label} failed validation: sequential parsing requires an object schema.`);
446
+ }
447
+ const record = requireRecordSequential(schema, payload, label);
448
+ return walkObjectSequential(schema, record, label);
449
+ }
450
+ // --- reviewer payload (strict unknown-key family) ----------------------------
451
+ // The strict reviewer family additionally enforced, at each object level and
452
+ // BEFORE reading any declared field: required own properties (in key order,
453
+ // via Object.prototype.hasOwnProperty — prototype-inherited properties are
454
+ // rejected and their accessors never invoked) and unknown-key rejection (in
455
+ // input order, via Object.keys). The historical sequence, reproduced below:
456
+ // object-ness, missing required own properties, unknown properties, findings
457
+ // array-ness, summary, each finding in order (object-ness, missing, unknown,
458
+ // files array-ness, severity, files items, claim, evidence, requiredAction),
459
+ // then meaningfulProgressAction and meaningfulProgressRationale.
460
+ function assertReviewerOwnProperties(record, shape, label) {
461
+ for (const key of Object.keys(shape)) {
462
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
82
463
  throw new Error(`${label} is missing required property "${key}".`);
83
464
  }
84
465
  }
85
- for (const key of Object.keys(value)) {
86
- if (!requiredKeys.includes(key)) {
466
+ for (const key of Object.keys(record)) {
467
+ if (!Object.prototype.hasOwnProperty.call(shape, key)) {
87
468
  throw new Error(`${label} included unknown property "${key}".`);
88
469
  }
89
470
  }
90
471
  }
91
- function requireReviewerString(value, fieldPath) {
92
- if (typeof value !== 'string') {
93
- throw new Error(`${fieldPath} must be a string.`);
94
- }
95
- return value;
96
- }
97
- function requireReviewerEnum(value, fieldPath, allowedValues) {
98
- if (typeof value !== 'string' || !allowedValues.includes(value)) {
99
- throw new Error(`${fieldPath} must be exactly one of: ${allowedValues.join(', ')}.`);
472
+ function validateReviewerFindingSequential(finding, index) {
473
+ const label = `Reviewer payload.findings[${index}]`;
474
+ const record = requireRecordSequential(reviewerFindingSchema, finding, label);
475
+ const shape = reviewerFindingSchema.shape;
476
+ assertReviewerOwnProperties(record, shape, label);
477
+ // Historical order and access pattern: the files array-ness check read
478
+ // `files` before severity (or any other declared field) was read, and the
479
+ // items mapping below RE-READS the property after severity, so a stateful
480
+ // accessor's second read supplies the validated items.
481
+ const filesProbe = record.files;
482
+ if (!Array.isArray(filesProbe)) {
483
+ throw new Error(firstSchemaIssueMessage(shape.files, filesProbe, `${label}.files`));
100
484
  }
101
- return value;
102
- }
103
- export function buildPlanReviewerSchema() {
104
485
  return {
105
- type: 'object',
106
- properties: {
107
- summary: { type: 'string' },
108
- executionShape: { type: 'string', enum: ['one_shot', 'multi_scope', 'multi_scope_unknown'] },
109
- findings: {
110
- type: 'array',
111
- items: {
112
- type: 'object',
113
- properties: {
114
- severity: { type: 'string', enum: ['blocking', 'non_blocking'] },
115
- files: { type: 'array', items: { type: 'string' } },
116
- claim: { type: 'string' },
117
- requiredAction: { type: 'string' },
118
- },
119
- required: ['severity', 'files', 'claim', 'requiredAction'],
120
- additionalProperties: false,
121
- },
122
- },
123
- },
124
- required: ['summary', 'executionShape', 'findings'],
125
- additionalProperties: false,
486
+ severity: validateFieldSequential(shape.severity, record.severity, `${label}.severity`),
487
+ files: record.files.map((file, fileIndex) => validateFieldSequential(shape.files.element, file, `${label}.files[${fileIndex}]`)),
488
+ claim: validateFieldSequential(shape.claim, record.claim, `${label}.claim`),
489
+ evidence: validateFieldSequential(shape.evidence, record.evidence, `${label}.evidence`),
490
+ requiredAction: validateFieldSequential(shape.requiredAction, record.requiredAction, `${label}.requiredAction`),
126
491
  };
127
492
  }
128
- export function buildBlockedAdjudicatorSchema() {
493
+ export function validateReviewerPayload(payload) {
494
+ const record = requireRecordSequential(reviewerPayloadSchema, payload, 'Reviewer payload');
495
+ const shape = reviewerPayloadSchema.shape;
496
+ assertReviewerOwnProperties(record, shape, 'Reviewer payload');
497
+ // Historical access pattern: the array-ness check performs its own read,
498
+ // and the mapping below re-reads the property (after summary), so a
499
+ // stateful accessor's second read is the one validated and normalized.
500
+ const findingsProbe = record.findings;
501
+ if (!Array.isArray(findingsProbe)) {
502
+ throw new Error(firstSchemaIssueMessage(shape.findings, findingsProbe, 'Reviewer payload.findings'));
503
+ }
129
504
  return {
130
- type: 'object',
131
- properties: {
132
- recoverable: { type: 'boolean' },
133
- triageCategory: {
134
- type: 'string',
135
- enum: ['misunderstanding', 'authorization', 'external_precondition', 'impossible_task'],
136
- },
137
- resolutionDirective: { type: 'string' },
138
- targetCanonicalIds: { type: 'array', items: { type: 'string' } },
139
- rationale: { type: 'string' },
140
- },
141
- required: ['recoverable', 'triageCategory', 'resolutionDirective', 'rationale'],
142
- additionalProperties: false,
505
+ summary: validateFieldSequential(shape.summary, record.summary, 'Reviewer payload.summary'),
506
+ findings: record.findings.map((finding, index) => validateReviewerFindingSequential(finding, index)),
507
+ meaningfulProgressAction: validateFieldSequential(shape.meaningfulProgressAction, record.meaningfulProgressAction, 'Reviewer payload.meaningfulProgressAction'),
508
+ meaningfulProgressRationale: validateFieldSequential(shape.meaningfulProgressRationale, record.meaningfulProgressRationale, 'Reviewer payload.meaningfulProgressRationale'),
143
509
  };
144
510
  }
145
- // Validates a read-only review_stuck arbiter verdict. Exactly one triage
511
+ // --- permissive validators ----------------------------------------------------
512
+ // Validates a read-only review_stuck consultant verdict. Exactly one triage
146
513
  // category is autonomously recoverable: `misunderstanding` requires
147
514
  // recoverable=true plus a non-empty resolutionDirective; the three genuine-wall
148
515
  // categories (`authorization`, `external_precondition`, `impossible_task`)
149
516
  // require recoverable=false. Anything else (recoverable=true with another
150
517
  // triage, or recoverable=false paired with `misunderstanding`) is rejected so a
151
518
  // malformed verdict can never drive an autonomous recovery.
152
- export function validateBlockedAdjudicatorVerdictPayload(rawPayload) {
153
- const value = requirePayloadObject(rawPayload, 'Review-stuck arbiter payload');
519
+ export function validateConsultantVerdictPayload(rawPayload) {
520
+ const parsed = parsePayload(consultantPayloadSchema, rawPayload, 'Consultant payload');
154
521
  const payload = {
155
- recoverable: requirePayloadBoolean(value.recoverable, 'Review-stuck arbiter payload.recoverable'),
156
- triageCategory: requirePayloadEnum(value.triageCategory, 'Review-stuck arbiter payload.triageCategory', [
157
- 'misunderstanding',
158
- 'authorization',
159
- 'external_precondition',
160
- 'impossible_task',
161
- ]),
162
- resolutionDirective: requirePayloadString(value.resolutionDirective, 'Review-stuck arbiter payload.resolutionDirective'),
522
+ recoverable: parsed.recoverable,
523
+ triageCategory: parsed.triageCategory,
524
+ resolutionDirective: parsed.resolutionDirective,
163
525
  // The verdict's targetCanonicalIds is optional: a coder/split-plan block has
164
526
  // no reviewer findings to point at, so an absent value defaults to [].
165
- targetCanonicalIds: value.targetCanonicalIds === undefined
166
- ? []
167
- : requirePayloadStringArray(value.targetCanonicalIds, 'Review-stuck arbiter payload.targetCanonicalIds'),
168
- rationale: requirePayloadString(value.rationale, 'Review-stuck arbiter payload.rationale'),
527
+ targetCanonicalIds: parsed.targetCanonicalIds ?? [],
528
+ rationale: parsed.rationale,
169
529
  };
170
530
  if (!payload.rationale.trim()) {
171
- throw new Error('Review-stuck arbiter returned an empty rationale.');
531
+ throw new Error('Consultant returned an empty rationale.');
172
532
  }
173
533
  if (payload.recoverable) {
174
534
  if (payload.triageCategory !== 'misunderstanding') {
175
- throw new Error('Review-stuck arbiter returned recoverable=true with a triageCategory other than misunderstanding.');
535
+ throw new Error('Consultant returned recoverable=true with a triageCategory other than misunderstanding.');
176
536
  }
177
537
  if (!payload.resolutionDirective.trim()) {
178
- throw new Error('Review-stuck arbiter returned recoverable=true without a non-empty resolutionDirective.');
538
+ throw new Error('Consultant returned recoverable=true without a non-empty resolutionDirective.');
179
539
  }
180
540
  }
181
541
  else if (payload.triageCategory === 'misunderstanding') {
182
- throw new Error('Review-stuck arbiter returned recoverable=false paired with triageCategory=misunderstanding.');
542
+ throw new Error('Consultant returned recoverable=false paired with triageCategory=misunderstanding.');
183
543
  }
184
544
  return payload;
185
545
  }
186
546
  export function validatePlanReviewerPayload(payload) {
187
- const value = requirePayloadObject(payload, 'Plan reviewer payload');
547
+ const parsed = parsePayload(planReviewerPayloadSchema, payload, 'Plan reviewer payload');
188
548
  return {
189
- summary: requirePayloadString(value.summary, 'Plan reviewer payload.summary'),
190
- executionShape: requirePayloadEnum(value.executionShape, 'Plan reviewer payload.executionShape', [
191
- 'one_shot',
192
- 'multi_scope',
193
- 'multi_scope_unknown',
194
- ]),
195
- findings: requirePayloadObjectArray(value.findings, 'Plan reviewer payload.findings').map((finding, index) => ({
196
- severity: requirePayloadEnum(finding.severity, `Plan reviewer payload.findings[${index}].severity`, [
197
- 'blocking',
198
- 'non_blocking',
199
- ]),
200
- files: requirePayloadStringArray(finding.files, `Plan reviewer payload.findings[${index}].files`),
201
- claim: requirePayloadString(finding.claim, `Plan reviewer payload.findings[${index}].claim`),
549
+ summary: parsed.summary,
550
+ executionShape: parsed.executionShape,
551
+ findings: parsed.findings.map((finding) => ({
552
+ severity: finding.severity,
553
+ files: finding.files,
554
+ claim: finding.claim,
555
+ // Plan-review findings carry no evidence field; the normalized finding
556
+ // always forces an empty string.
202
557
  evidence: '',
203
- requiredAction: requirePayloadString(finding.requiredAction, `Plan reviewer payload.findings[${index}].requiredAction`),
558
+ requiredAction: finding.requiredAction,
559
+ // Default only the absent case to plan_correctness (the fail-safe class).
560
+ // A present-but-invalid class was already rejected upstream in parsePayload,
561
+ // so this never silently downgrades a bad value.
562
+ findingClass: finding.findingClass ?? 'plan_correctness',
204
563
  })),
205
564
  };
206
565
  }
207
- export function buildCoderResponseSchema() {
208
- return {
209
- type: 'object',
210
- properties: {
211
- outcome: { type: 'string', enum: ['responded', 'blocked', 'split_plan'] },
212
- summary: { type: 'string' },
213
- blocker: { type: 'string' },
214
- derivedPlan: { type: 'string' },
215
- responses: {
216
- type: 'array',
217
- items: {
218
- type: 'object',
219
- properties: {
220
- id: { type: 'string' },
221
- decision: { type: 'string', enum: ['fixed', 'rejected', 'deferred'] },
222
- summary: { type: 'string' },
223
- },
224
- required: ['id', 'decision', 'summary'],
225
- additionalProperties: false,
226
- },
227
- },
228
- },
229
- required: ['outcome', 'summary', 'blocker', 'derivedPlan', 'responses'],
230
- additionalProperties: false,
231
- };
232
- }
233
- export function buildCoderBlockedRecoveryDispositionSchema() {
234
- return {
235
- type: 'object',
236
- properties: {
237
- action: {
238
- type: 'string',
239
- enum: ['resume_current_scope', 'replace_current_scope', 'stay_blocked', 'terminal_block'],
240
- },
241
- summary: { type: 'string' },
242
- rationale: { type: 'string' },
243
- blocker: { type: 'string' },
244
- replacementPlan: { type: 'string' },
245
- },
246
- required: ['action', 'summary', 'rationale', 'blocker', 'replacementPlan'],
247
- additionalProperties: false,
248
- };
249
- }
250
- export function buildCoderPlanResponseSchema() {
251
- return {
252
- type: 'object',
253
- properties: {
254
- outcome: { type: 'string', enum: ['responded', 'blocked'] },
255
- summary: { type: 'string' },
256
- blocker: { type: 'string' },
257
- responses: {
258
- type: 'array',
259
- items: {
260
- type: 'object',
261
- properties: {
262
- id: { type: 'string' },
263
- decision: { type: 'string', enum: ['fixed', 'rejected', 'deferred'] },
264
- summary: { type: 'string' },
265
- },
266
- required: ['id', 'decision', 'summary'],
267
- additionalProperties: false,
268
- },
269
- },
270
- },
271
- required: ['outcome', 'summary', 'blocker', 'responses'],
272
- additionalProperties: false,
273
- };
274
- }
275
- export function buildCoderPlanSchema() {
276
- return {
277
- type: 'object',
278
- properties: {
279
- action: { type: 'string', enum: ['ready_for_review', 'blocked'] },
280
- message: { type: 'string' },
281
- executionShape: { type: 'string', enum: ['one_shot', 'multi_scope', 'multi_scope_unknown'] },
282
- planBody: { type: 'string' },
283
- blockedReason: { type: 'string' },
284
- },
285
- required: ['action', 'message', 'executionShape', 'planBody', 'blockedReason'],
286
- additionalProperties: false,
287
- };
288
- }
289
- export function buildCoderScopeSchema() {
290
- const manualGateResumeCheckSchema = {
291
- type: 'object',
292
- properties: {
293
- type: { type: 'string', enum: ['command'] },
294
- name: { type: 'string' },
295
- command: {
296
- type: 'array',
297
- items: { type: 'string' },
298
- },
299
- cwd: { type: ['string', 'null'], enum: ['repo', 'run_dir', null] },
300
- timeoutMs: { type: ['number', 'null'] },
301
- },
302
- required: ['type', 'name', 'command', 'cwd', 'timeoutMs'],
303
- additionalProperties: false,
304
- };
305
- return {
306
- type: 'object',
307
- properties: {
308
- action: { type: 'string', enum: ['continue', 'scope_done', 'done', 'blocked', 'split_plan', 'manual_gate'] },
309
- message: { type: 'string' },
310
- progress: buildExecuteScopeProgressSchema(),
311
- manualGate: {
312
- type: ['object', 'null'],
313
- properties: {
314
- id: { type: 'string' },
315
- title: { type: 'string' },
316
- reason: { type: 'string' },
317
- instructionsMarkdown: { type: 'string' },
318
- resumeChecks: {
319
- type: 'array',
320
- items: manualGateResumeCheckSchema,
321
- },
322
- },
323
- required: ['id', 'title', 'reason', 'instructionsMarkdown', 'resumeChecks'],
324
- additionalProperties: false,
325
- },
326
- derivedPlan: { type: 'string' },
327
- blockedReason: { type: 'string' },
328
- },
329
- required: ['action', 'message', 'progress', 'manualGate', 'derivedPlan', 'blockedReason'],
330
- additionalProperties: false,
331
- };
332
- }
333
- export function buildExecuteScopeProgressSchema() {
334
- return {
335
- type: 'object',
336
- properties: {
337
- milestoneTargeted: { type: 'string' },
338
- newEvidence: { type: 'string' },
339
- whyNotRedundant: { type: 'string' },
340
- nextStepUnlocked: { type: 'string' },
341
- },
342
- required: ['milestoneTargeted', 'newEvidence', 'whyNotRedundant', 'nextStepUnlocked'],
343
- additionalProperties: false,
344
- };
345
- }
346
- export function buildFinalCompletionSummarySchema() {
347
- return {
348
- type: 'object',
349
- properties: {
350
- planGoalSatisfied: { type: 'boolean' },
351
- whatChangedOverall: { type: 'string' },
352
- verificationSummary: { type: 'string' },
353
- remainingKnownGaps: {
354
- type: 'array',
355
- items: { type: 'string' },
356
- },
357
- },
358
- required: ['planGoalSatisfied', 'whatChangedOverall', 'verificationSummary', 'remainingKnownGaps'],
359
- additionalProperties: false,
360
- };
361
- }
362
- export function buildFinalCompletionReviewerSchema() {
363
- return {
364
- type: 'object',
365
- properties: {
366
- action: { type: 'string', enum: ['accept_complete', 'continue_execution', 'block_for_operator'] },
367
- summary: { type: 'string' },
368
- rationale: { type: 'string' },
369
- missingWork: {
370
- type: ['object', 'null'],
371
- properties: {
372
- summary: { type: 'string' },
373
- requiredOutcome: { type: 'string' },
374
- verification: { type: 'string' },
375
- },
376
- required: ['summary', 'requiredOutcome', 'verification'],
377
- additionalProperties: false,
378
- },
379
- squashCommitMessage: {
380
- description: 'Project-facing Git history metadata used only when action is accept_complete; set null for continue_execution or block_for_operator.',
381
- type: ['object', 'null'],
382
- properties: {
383
- subject: {
384
- description: 'Concise project-facing commit subject summarizing code or product behavior, not plan documents, paths, scopes, Neal mechanics, provider process, reviewer process, or final cleanup.',
385
- type: 'string',
386
- },
387
- bullets: {
388
- description: 'Two to five project-facing Git history bullets summarizing behavior changes; avoid plan paths, markdown plan filenames, temporary run paths, scope wording, Neal mechanics, provider process, or reviewer process.',
389
- type: 'array',
390
- minItems: 2,
391
- maxItems: 5,
392
- items: { type: 'string' },
393
- },
394
- },
395
- required: ['subject', 'bullets'],
396
- additionalProperties: false,
397
- },
398
- },
399
- required: ['action', 'summary', 'rationale', 'missingWork', 'squashCommitMessage'],
400
- additionalProperties: false,
401
- };
402
- }
403
566
  function parseJsonPayload(raw, label) {
404
567
  try {
405
568
  return JSON.parse(raw);
@@ -435,54 +598,15 @@ function extractDelimitedPayload(raw, label) {
435
598
  };
436
599
  }
437
600
  function requireNonEmptyString(value, field, label) {
438
- if (typeof value !== 'string' || value.trim().length === 0) {
601
+ // Base string-ness is enforced by the field's zod node from
602
+ // executeScopeProgressSchema; the non-empty rule, trimming, and the pinned
603
+ // delimiter-protocol message (shared by missing, wrong-typed, and blank
604
+ // values) are runtime semantics layered on top.
605
+ const parsed = executeScopeProgressSchema.shape[field].safeParse(value);
606
+ if (!parsed.success || parsed.data.trim().length === 0) {
439
607
  throw new Error(`${label} returned an empty or missing ${field} field in the progress-justification payload.`);
440
608
  }
441
- return value.trim();
442
- }
443
- function requirePayloadObject(value, label) {
444
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
445
- throw new Error(`${label} must be a non-null object.`);
446
- }
447
- return value;
448
- }
449
- function requirePayloadString(value, fieldPath) {
450
- if (typeof value !== 'string') {
451
- throw new Error(`${fieldPath} must be a string.`);
452
- }
453
- return value;
454
- }
455
- function requirePayloadBoolean(value, fieldPath) {
456
- if (typeof value !== 'boolean') {
457
- throw new Error(`${fieldPath} must be a boolean.`);
458
- }
459
- return value;
460
- }
461
- function requirePayloadStringArray(value, fieldPath) {
462
- if (!Array.isArray(value)) {
463
- throw new Error(`${fieldPath} must be an array.`);
464
- }
465
- return value.map((item, index) => requirePayloadString(item, `${fieldPath}[${index}]`));
466
- }
467
- function requirePayloadObjectArray(value, fieldPath) {
468
- if (!Array.isArray(value)) {
469
- throw new Error(`${fieldPath} must be an array.`);
470
- }
471
- return value.map((item, index) => requirePayloadObject(item, `${fieldPath}[${index}]`));
472
- }
473
- function requirePayloadEnum(value, fieldPath, allowedValues) {
474
- if (typeof value !== 'string' || !allowedValues.includes(value)) {
475
- throw new Error(`${fieldPath} must be exactly one of: ${allowedValues.join(', ')}.`);
476
- }
477
- return value;
478
- }
479
- function validateCoderFindingResponse(value, fieldPath) {
480
- const response = requirePayloadObject(value, fieldPath);
481
- return {
482
- id: requirePayloadString(response.id, `${fieldPath}.id`),
483
- decision: requirePayloadEnum(response.decision, `${fieldPath}.decision`, ['fixed', 'rejected', 'deferred']),
484
- summary: requirePayloadString(response.summary, `${fieldPath}.summary`),
485
- };
609
+ return parsed.data.trim();
486
610
  }
487
611
  // Legacy marker-protocol compatibility for active legacy_marker_v1 execution
488
612
  // sessions. New primary execution sessions use CoderScopePayload instead.
@@ -507,18 +631,7 @@ export function stripExecuteScopeProgressPayload(raw) {
507
631
  return before || after;
508
632
  }
509
633
  export function validateCoderResponsePayload(rawPayload) {
510
- const value = requirePayloadObject(rawPayload, 'Coder response round payload');
511
- const payload = {
512
- outcome: requirePayloadEnum(value.outcome, 'Coder response round payload.outcome', [
513
- 'responded',
514
- 'blocked',
515
- 'split_plan',
516
- ]),
517
- summary: requirePayloadString(value.summary, 'Coder response round payload.summary'),
518
- blocker: requirePayloadString(value.blocker, 'Coder response round payload.blocker'),
519
- derivedPlan: requirePayloadString(value.derivedPlan, 'Coder response round payload.derivedPlan'),
520
- responses: requirePayloadObjectArray(value.responses, 'Coder response round payload.responses').map((response, index) => validateCoderFindingResponse(response, `Coder response round payload.responses[${index}]`)),
521
- };
634
+ const payload = parsePayload(coderResponsePayloadSchema, rawPayload, 'Coder response round payload');
522
635
  const derivedPlan = payload.derivedPlan?.trim() ?? '';
523
636
  if (payload.outcome === 'split_plan' && !derivedPlan) {
524
637
  throw new Error('Coder response round returned outcome=split_plan without a derivedPlan payload.');
@@ -529,33 +642,10 @@ export function validateCoderResponsePayload(rawPayload) {
529
642
  return payload;
530
643
  }
531
644
  export function validateCoderPlanResponsePayload(payload) {
532
- const value = requirePayloadObject(payload, 'Planner plan-response round payload');
533
- return {
534
- outcome: requirePayloadEnum(value.outcome, 'Planner plan-response round payload.outcome', [
535
- 'responded',
536
- 'blocked',
537
- ]),
538
- summary: requirePayloadString(value.summary, 'Planner plan-response round payload.summary'),
539
- blocker: requirePayloadString(value.blocker, 'Planner plan-response round payload.blocker'),
540
- responses: requirePayloadObjectArray(value.responses, 'Planner plan-response round payload.responses').map((response, index) => validateCoderFindingResponse(response, `Planner plan-response round payload.responses[${index}]`)),
541
- };
645
+ return parsePayload(coderPlanResponsePayloadSchema, payload, 'Planner plan-response round payload');
542
646
  }
543
647
  export function validateCoderPlanPayload(rawPayload) {
544
- const value = requirePayloadObject(rawPayload, 'Planner plan round payload');
545
- const payload = {
546
- action: requirePayloadEnum(value.action, 'Planner plan round payload.action', [
547
- 'ready_for_review',
548
- 'blocked',
549
- ]),
550
- message: requirePayloadString(value.message, 'Planner plan round payload.message'),
551
- executionShape: requirePayloadEnum(value.executionShape, 'Planner plan round payload.executionShape', [
552
- 'one_shot',
553
- 'multi_scope',
554
- 'multi_scope_unknown',
555
- ]),
556
- planBody: requirePayloadString(value.planBody, 'Planner plan round payload.planBody'),
557
- blockedReason: requirePayloadString(value.blockedReason, 'Planner plan round payload.blockedReason'),
558
- };
648
+ const payload = parsePayload(coderPlanPayloadSchema, rawPayload, 'Planner plan round payload');
559
649
  const planBody = payload.planBody.trim();
560
650
  const blockedReason = payload.blockedReason.trim();
561
651
  if (payload.action === 'ready_for_review' && !planBody) {
@@ -567,29 +657,36 @@ export function validateCoderPlanPayload(rawPayload) {
567
657
  if (payload.action === 'blocked' && !blockedReason) {
568
658
  throw new Error('Planner plan round returned action=blocked without a blockedReason payload.');
569
659
  }
660
+ // The refined plan is persisted over the plan document and sent straight to
661
+ // plan review, so it must satisfy the plan contract here — inside the
662
+ // structured-output validator — where a failure triggers the repair loop
663
+ // (the planner gets the errors and retries) instead of burning a reviewer
664
+ // round on an invalid document. Split-plan payloads already have this gate
665
+ // (validateSplitPlanPayload); a live run persisted a planner payload whose
666
+ // planBody was a 39-line refinement summary declaring multi_scope with no
667
+ // Execution Queue, and the reviewer round was spent rediscovering that.
668
+ if (payload.action === 'ready_for_review') {
669
+ const normalizedBody = normalizeExecutionShapeDeclaration(planBody, payload.executionShape);
670
+ const validation = validatePlanDocument(normalizedBody);
671
+ if (!validation.ok) {
672
+ throw new Error(`Planner plan round returned a planBody that is not a valid Neal plan document: ${validation.errors.join('; ')}. ` +
673
+ 'Return the complete refined plan document, not a summary of the refinement.');
674
+ }
675
+ }
570
676
  return {
571
677
  ...payload,
572
678
  message: payload.message.trim(),
573
679
  };
574
680
  }
575
681
  export function validateCoderScopePayload(rawPayload) {
576
- const value = requirePayloadObject(rawPayload, 'Coder scope round payload');
682
+ const parsed = parsePayload(coderScopeParseSchema, rawPayload, 'Coder scope round payload');
577
683
  const payload = {
578
- action: requirePayloadEnum(value.action, 'Coder scope round payload.action', [
579
- 'continue',
580
- 'scope_done',
581
- 'done',
582
- 'blocked',
583
- 'split_plan',
584
- 'manual_gate',
585
- ]),
586
- message: requirePayloadString(value.message, 'Coder scope round payload.message'),
587
- progress: requirePayloadObject(value.progress, 'Coder scope round payload.progress'),
588
- manualGate: value.manualGate === null
589
- ? null
590
- : requirePayloadObject(value.manualGate, 'Coder scope round payload.manualGate'),
591
- derivedPlan: requirePayloadString(value.derivedPlan, 'Coder scope round payload.derivedPlan'),
592
- blockedReason: requirePayloadString(value.blockedReason, 'Coder scope round payload.blockedReason'),
684
+ action: parsed.action,
685
+ message: parsed.message,
686
+ progress: parsed.progress,
687
+ manualGate: parsed.manualGate,
688
+ derivedPlan: parsed.derivedPlan,
689
+ blockedReason: parsed.blockedReason,
593
690
  };
594
691
  const progress = {
595
692
  milestoneTargeted: requireNonEmptyString(payload.progress?.milestoneTargeted, 'milestoneTargeted', 'Coder scope round'),
@@ -622,10 +719,10 @@ export function validateCoderScopePayload(rawPayload) {
622
719
  if (blockedReason) {
623
720
  throw new Error('Coder scope round returned a blockedReason payload with action=manual_gate.');
624
721
  }
625
- const id = requireNonEmptyManualGateString(manualGate.id, 'manualGate.id');
626
- const title = requireNonEmptyManualGateString(manualGate.title, 'manualGate.title');
627
- const reason = requireNonEmptyManualGateString(manualGate.reason, 'manualGate.reason');
628
- const instructionsMarkdown = requireNonEmptyManualGateString(manualGate.instructionsMarkdown, 'manualGate.instructionsMarkdown');
722
+ const id = requireNonEmptyManualGateString(manualGateSchema.shape.id, manualGate.id, 'manualGate.id');
723
+ const title = requireNonEmptyManualGateString(manualGateSchema.shape.title, manualGate.title, 'manualGate.title');
724
+ const reason = requireNonEmptyManualGateString(manualGateSchema.shape.reason, manualGate.reason, 'manualGate.reason');
725
+ const instructionsMarkdown = requireNonEmptyManualGateString(manualGateSchema.shape.instructionsMarkdown, manualGate.instructionsMarkdown, 'manualGate.instructionsMarkdown');
629
726
  const resumeChecks = validateManualGateResumeChecks(manualGate.resumeChecks);
630
727
  return {
631
728
  ...payload,
@@ -649,39 +746,72 @@ export function validateCoderScopePayload(rawPayload) {
649
746
  progress,
650
747
  };
651
748
  }
652
- function requireNonEmptyManualGateString(value, fieldPath) {
653
- if (typeof value !== 'string' || value.trim().length === 0) {
749
+ // Base-constraint nodes for the resume-check cwd/timeoutMs fields, unwrapped
750
+ // from the emitted schema's nullable wrappers. Runtime optionality
751
+ // intentionally differs from the emitted schema's requiredness (the runtime
752
+ // validator accepts absence where the emitted schema requires the key), and
753
+ // the historical validator performed ONE property read per comparison and
754
+ // re-read the property for the normalized output — stateful accessors
755
+ // legitimately observe multiple short-circuited reads. The membership
756
+ // literals are therefore destructured from the zod enum node (one comparison
757
+ // per option, per read, as historically), and number-ness parses through the
758
+ // zod number node at its historical read position.
759
+ const resumeCheckCwdOptions = manualGateResumeCheckSchema.shape.cwd.unwrap().options;
760
+ const resumeCheckTimeoutMsNumberSchema = manualGateResumeCheckSchema.shape.timeoutMs.unwrap();
761
+ function requireNonEmptyManualGateString(fieldSchema, value, fieldPath) {
762
+ // Base string-ness is enforced by the field's zod node; the non-empty rule,
763
+ // trimming, and the pinned message (shared by missing, wrong-typed, and
764
+ // blank values) are runtime semantics layered on top.
765
+ const parsed = fieldSchema.safeParse(value);
766
+ if (!parsed.success || typeof parsed.data !== 'string' || parsed.data.trim().length === 0) {
654
767
  throw new Error(`Coder scope round returned an empty or missing ${fieldPath} field.`);
655
768
  }
656
- return value.trim();
769
+ return parsed.data.trim();
657
770
  }
658
771
  function validateManualGateResumeChecks(value) {
772
+ // The at-least-one-command rule is runtime-only semantics (the emitted
773
+ // schema does not constrain resumeChecks length), and its pinned message
774
+ // covers the array-ness base constraint too.
659
775
  if (!Array.isArray(value) || value.length === 0) {
660
776
  throw new Error('Coder scope round returned manualGate.resumeChecks without at least one command check.');
661
777
  }
778
+ const checkShape = manualGateResumeCheckSchema.shape;
662
779
  return value.map((check, index) => {
663
780
  const fieldPath = `manualGate.resumeChecks[${index}]`;
781
+ // Historical container gate with its pinned message; array items
782
+ // intentionally fall through to the type check below, exactly as the
783
+ // old typeof-based gate behaved.
664
784
  if (check === null || typeof check !== 'object') {
665
785
  throw new Error(`Coder scope round returned invalid ${fieldPath}.`);
666
786
  }
667
787
  const candidate = check;
668
- if (candidate.type !== 'command') {
788
+ if (!checkShape.type.safeParse(candidate.type).success) {
669
789
  throw new Error(`Coder scope round returned ${fieldPath}.type that is not "command".`);
670
790
  }
671
- const name = requireNonEmptyManualGateString(candidate.name, `${fieldPath}.name`);
791
+ const name = requireNonEmptyManualGateString(checkShape.name, candidate.name, `${fieldPath}.name`);
792
+ // Emptiness is runtime-only semantics with a pinned message that also
793
+ // covers array-ness; each part's base string-ness routes through the
794
+ // command element's zod node below.
672
795
  if (!Array.isArray(candidate.command) || candidate.command.length === 0) {
673
796
  throw new Error(`Coder scope round returned ${fieldPath}.command without a non-empty string array.`);
674
797
  }
675
- const command = candidate.command.map((part, commandIndex) => requireNonEmptyManualGateString(part, `${fieldPath}.command[${commandIndex}]`));
798
+ const command = candidate.command.map((part, commandIndex) => requireNonEmptyManualGateString(checkShape.command.element, part, `${fieldPath}.command[${commandIndex}]`));
799
+ // Historical access semantics preserved exactly: one property read per
800
+ // comparison with short-circuiting, and fresh reads for the normalized
801
+ // output below. The undefined/null comparisons carry the runtime
802
+ // optionality; membership tests one zod enum option per read; the
803
+ // positive-safe-integer rule stays explicit runtime semantics.
676
804
  if (candidate.cwd !== undefined &&
677
805
  candidate.cwd !== null &&
678
- candidate.cwd !== 'repo' &&
679
- candidate.cwd !== 'run_dir') {
806
+ candidate.cwd !== resumeCheckCwdOptions[0] &&
807
+ candidate.cwd !== resumeCheckCwdOptions[1]) {
680
808
  throw new Error(`Coder scope round returned ${fieldPath}.cwd that is not "repo" or "run_dir".`);
681
809
  }
682
810
  if (candidate.timeoutMs !== undefined &&
683
811
  candidate.timeoutMs !== null &&
684
- (typeof candidate.timeoutMs !== 'number' || !Number.isSafeInteger(candidate.timeoutMs) || candidate.timeoutMs < 1)) {
812
+ (!resumeCheckTimeoutMsNumberSchema.safeParse(candidate.timeoutMs).success ||
813
+ !Number.isSafeInteger(candidate.timeoutMs) ||
814
+ candidate.timeoutMs < 1)) {
685
815
  throw new Error(`Coder scope round returned ${fieldPath}.timeoutMs that is not a positive safe integer.`);
686
816
  }
687
817
  return {
@@ -693,23 +823,8 @@ function validateManualGateResumeChecks(value) {
693
823
  };
694
824
  });
695
825
  }
696
- function nonEmptyTrimmedItems(items) {
697
- return items.map((item) => item.trim()).filter((item) => item.length > 0);
698
- }
699
826
  export function validateCoderBlockedRecoveryDispositionPayload(rawPayload) {
700
- const value = requirePayloadObject(rawPayload, 'Coder blocked-recovery payload');
701
- const payload = {
702
- action: requirePayloadEnum(value.action, 'Coder blocked-recovery payload.action', [
703
- 'resume_current_scope',
704
- 'replace_current_scope',
705
- 'stay_blocked',
706
- 'terminal_block',
707
- ]),
708
- summary: requirePayloadString(value.summary, 'Coder blocked-recovery payload.summary'),
709
- rationale: requirePayloadString(value.rationale, 'Coder blocked-recovery payload.rationale'),
710
- blocker: requirePayloadString(value.blocker, 'Coder blocked-recovery payload.blocker'),
711
- replacementPlan: requirePayloadString(value.replacementPlan, 'Coder blocked-recovery payload.replacementPlan'),
712
- };
827
+ const payload = parsePayload(coderBlockedRecoveryDispositionPayloadSchema, rawPayload, 'Coder blocked-recovery payload');
713
828
  const blocker = payload.blocker.trim();
714
829
  const replacementPlan = payload.replacementPlan.trim();
715
830
  if (payload.action === 'replace_current_scope' && !replacementPlan) {
@@ -724,13 +839,7 @@ export function validateCoderBlockedRecoveryDispositionPayload(rawPayload) {
724
839
  return payload;
725
840
  }
726
841
  export function parseFinalCompletionSummaryPayload(rawPayload) {
727
- const value = requirePayloadObject(rawPayload, 'Final completion summary payload');
728
- const payload = {
729
- planGoalSatisfied: requirePayloadBoolean(value.planGoalSatisfied, 'Final completion summary payload.planGoalSatisfied'),
730
- whatChangedOverall: requirePayloadString(value.whatChangedOverall, 'Final completion summary payload.whatChangedOverall'),
731
- verificationSummary: requirePayloadString(value.verificationSummary, 'Final completion summary payload.verificationSummary'),
732
- remainingKnownGaps: requirePayloadStringArray(value.remainingKnownGaps, 'Final completion summary payload.remainingKnownGaps'),
733
- };
842
+ const payload = parsePayload(finalCompletionSummaryPayloadSchema, rawPayload, 'Final completion summary payload');
734
843
  const whatChangedOverall = payload.whatChangedOverall.trim();
735
844
  const verificationSummary = payload.verificationSummary.trim();
736
845
  const remainingKnownGaps = payload.remainingKnownGaps
@@ -756,37 +865,41 @@ export function parseFinalCompletionSummaryPayload(rawPayload) {
756
865
  };
757
866
  }
758
867
  export function parseFinalCompletionReviewerPayload(rawPayload) {
759
- const value = requirePayloadObject(rawPayload, 'Final completion reviewer verdict payload');
760
- const action = requirePayloadEnum(value.action, 'Final completion reviewer verdict payload.action', [
761
- 'accept_complete',
762
- 'continue_execution',
763
- 'block_for_operator',
764
- ]);
765
- const missingWorkValue = value.missingWork;
766
- const missingWorkShape = missingWorkValue === null
868
+ // Historical sequential order (pinned by test/agent-payload-schemas.test.ts,
869
+ // a deliberate deviation from shape order): action, missingWork
870
+ // object-ness, summary, rationale, missingWork nested fields, then
871
+ // squashCommitMessage object-ness. Each step short-circuits before any
872
+ // later property is read.
873
+ const label = 'Final completion reviewer verdict payload';
874
+ const record = requireRecordSequential(finalCompletionReviewerParseSchema, rawPayload, label);
875
+ const shape = finalCompletionReviewerParseSchema.shape;
876
+ const missingWorkSchema = finalCompletionReviewerPayloadSchema.shape.missingWork.unwrap();
877
+ const action = validateFieldSequential(shape.action, record.action, `${label}.action`);
878
+ const missingWorkValue = record.missingWork;
879
+ const missingWorkRecord = missingWorkValue === null
767
880
  ? null
768
- : requirePayloadObject(missingWorkValue, 'Final completion reviewer verdict payload.missingWork');
881
+ : requireRecordSequential(missingWorkSchema, missingWorkValue, `${label}.missingWork`);
882
+ const summaryValue = validateFieldSequential(shape.summary, record.summary, `${label}.summary`);
883
+ const rationaleValue = validateFieldSequential(shape.rationale, record.rationale, `${label}.rationale`);
769
884
  const payload = {
770
885
  action,
771
- summary: requirePayloadString(value.summary, 'Final completion reviewer verdict payload.summary'),
772
- rationale: requirePayloadString(value.rationale, 'Final completion reviewer verdict payload.rationale'),
773
- missingWork: missingWorkShape === null
886
+ summary: summaryValue,
887
+ rationale: rationaleValue,
888
+ missingWork: missingWorkRecord === null
774
889
  ? null
775
- : {
776
- summary: requirePayloadString(missingWorkShape.summary, 'Final completion reviewer verdict payload.missingWork.summary'),
777
- requiredOutcome: requirePayloadString(missingWorkShape.requiredOutcome, 'Final completion reviewer verdict payload.missingWork.requiredOutcome'),
778
- verification: requirePayloadString(missingWorkShape.verification, 'Final completion reviewer verdict payload.missingWork.verification'),
779
- },
780
- squashCommitMessage: value.squashCommitMessage === null || value.squashCommitMessage === undefined
890
+ : walkObjectSequential(missingWorkSchema, missingWorkRecord, `${label}.missingWork`),
891
+ // Historical access pattern: the null test, the undefined test, and the
892
+ // object-ness validation each read the property (three reads for a
893
+ // present draft, short-circuiting for null). Only object-ness is checked
894
+ // here — via the parse-variant zod node — because the advisory draft
895
+ // routes through the validate/repair post-parse step below.
896
+ squashCommitMessage: (record.squashCommitMessage === null || record.squashCommitMessage === undefined
781
897
  ? null
782
- : requirePayloadObject(value.squashCommitMessage, 'Final completion reviewer verdict payload.squashCommitMessage'),
898
+ : requireRecordSequential(shape.squashCommitMessage, record.squashCommitMessage, `${label}.squashCommitMessage`)),
783
899
  };
784
900
  const summary = payload.summary.trim();
785
901
  const rationale = payload.rationale.trim();
786
902
  const squashCommitMessageValue = payload.squashCommitMessage ?? null;
787
- if (!['accept_complete', 'continue_execution', 'block_for_operator'].includes(payload.action)) {
788
- throw new Error(`Final completion reviewer verdict returned unsupported action ${JSON.stringify(payload.action)}.`);
789
- }
790
903
  if (!summary) {
791
904
  throw new Error('Final completion reviewer verdict returned an empty summary field.');
792
905
  }
@@ -818,7 +931,7 @@ export function parseFinalCompletionReviewerPayload(rawPayload) {
818
931
  label: 'Final completion reviewer squashCommitMessage',
819
932
  });
820
933
  }
821
- catch (error) {
934
+ catch {
822
935
  squashCommitMessage = repairReviewerSquashMessageDraft(squashCommitMessageValue);
823
936
  }
824
937
  }