@navels/neal 0.1.0 → 0.2.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.
@@ -1,11 +1,8 @@
1
+ import { z } from 'zod';
1
2
  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'];
3
+ export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_START = 'NEAL_PROGRESS_JUSTIFICATION_JSON_START';
4
+ export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_END = 'NEAL_PROGRESS_JUSTIFICATION_JSON_END';
5
+ // --- zod payload definitions (single source of truth) -----------------------
9
6
  const REVIEWER_FINDING_SEVERITIES = ['blocking', 'non_blocking'];
10
7
  const REVIEWER_MEANINGFUL_PROGRESS_ACTIONS = [
11
8
  'accept',
@@ -13,135 +10,491 @@ const REVIEWER_MEANINGFUL_PROGRESS_ACTIONS = [
13
10
  'replace_plan',
14
11
  'advance_parent',
15
12
  ];
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';
13
+ const EXECUTION_SHAPES = ['one_shot', 'multi_scope', 'multi_scope_unknown'];
14
+ const BLOCKED_ADJUDICATOR_TRIAGE_CATEGORIES = [
15
+ 'misunderstanding',
16
+ 'authorization',
17
+ 'external_precondition',
18
+ 'impossible_task',
19
+ ];
20
+ const CODER_FINDING_RESPONSE_DECISIONS = ['fixed', 'rejected', 'deferred'];
21
+ const CODER_RESPONSE_OUTCOMES = ['responded', 'blocked', 'split_plan'];
22
+ const CODER_BLOCKED_RECOVERY_ACTIONS = [
23
+ 'resume_current_scope',
24
+ 'replace_current_scope',
25
+ 'stay_blocked',
26
+ 'terminal_block',
27
+ ];
28
+ const CODER_PLAN_RESPONSE_OUTCOMES = ['responded', 'blocked'];
29
+ const CODER_PLAN_ACTIONS = ['ready_for_review', 'blocked'];
30
+ const CODER_SCOPE_ACTIONS = ['continue', 'scope_done', 'done', 'blocked', 'split_plan', 'manual_gate'];
31
+ const MANUAL_GATE_RESUME_CHECK_TYPES = ['command'];
32
+ const MANUAL_GATE_RESUME_CHECK_CWDS = ['repo', 'run_dir'];
33
+ const FINAL_COMPLETION_REVIEWER_ACTIONS = ['accept_complete', 'continue_execution', 'block_for_operator'];
34
+ 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.';
35
+ 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.';
36
+ 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.';
37
+ // The reviewer payload is the strict unknown-key family: unknown properties
38
+ // are rejected at both the top level and inside findings (z.strictObject).
39
+ // Every other payload uses zod's default strip-mode z.object — unknown
40
+ // properties are accepted and omitted from the normalized output.
41
+ const reviewerFindingSchema = z.strictObject({
42
+ severity: z.enum(REVIEWER_FINDING_SEVERITIES),
43
+ files: z.array(z.string()),
44
+ claim: z.string(),
45
+ evidence: z.string(),
46
+ requiredAction: z.string(),
47
+ });
48
+ const reviewerPayloadSchema = z.strictObject({
49
+ summary: z.string(),
50
+ findings: z.array(reviewerFindingSchema),
51
+ meaningfulProgressAction: z.enum(REVIEWER_MEANINGFUL_PROGRESS_ACTIONS),
52
+ meaningfulProgressRationale: z.string(),
53
+ });
54
+ const planReviewerFindingSchema = z.object({
55
+ severity: z.enum(REVIEWER_FINDING_SEVERITIES),
56
+ files: z.array(z.string()),
57
+ claim: z.string(),
58
+ requiredAction: z.string(),
59
+ });
60
+ const planReviewerPayloadSchema = z.object({
61
+ summary: z.string(),
62
+ executionShape: z.enum(EXECUTION_SHAPES),
63
+ findings: z.array(planReviewerFindingSchema),
64
+ });
65
+ const blockedAdjudicatorPayloadSchema = z.object({
66
+ recoverable: z.boolean(),
67
+ triageCategory: z.enum(BLOCKED_ADJUDICATOR_TRIAGE_CATEGORIES),
68
+ resolutionDirective: z.string(),
69
+ targetCanonicalIds: z.array(z.string()).optional(),
70
+ rationale: z.string(),
71
+ });
72
+ const coderFindingResponseSchema = z.object({
73
+ id: z.string(),
74
+ decision: z.enum(CODER_FINDING_RESPONSE_DECISIONS),
75
+ summary: z.string(),
76
+ });
77
+ const coderResponsePayloadSchema = z.object({
78
+ outcome: z.enum(CODER_RESPONSE_OUTCOMES),
79
+ summary: z.string(),
80
+ blocker: z.string(),
81
+ derivedPlan: z.string(),
82
+ responses: z.array(coderFindingResponseSchema),
83
+ });
84
+ const coderBlockedRecoveryDispositionPayloadSchema = z.object({
85
+ action: z.enum(CODER_BLOCKED_RECOVERY_ACTIONS),
86
+ summary: z.string(),
87
+ rationale: z.string(),
88
+ blocker: z.string(),
89
+ replacementPlan: z.string(),
90
+ });
91
+ const coderPlanResponsePayloadSchema = z.object({
92
+ outcome: z.enum(CODER_PLAN_RESPONSE_OUTCOMES),
93
+ summary: z.string(),
94
+ blocker: z.string(),
95
+ responses: z.array(coderFindingResponseSchema),
96
+ });
97
+ const coderPlanPayloadSchema = z.object({
98
+ action: z.enum(CODER_PLAN_ACTIONS),
99
+ message: z.string(),
100
+ executionShape: z.enum(EXECUTION_SHAPES),
101
+ planBody: z.string(),
102
+ blockedReason: z.string(),
103
+ });
104
+ const executeScopeProgressSchema = z.object({
105
+ milestoneTargeted: z.string(),
106
+ newEvidence: z.string(),
107
+ whyNotRedundant: z.string(),
108
+ nextStepUnlocked: z.string(),
109
+ });
110
+ const manualGateResumeCheckSchema = z.object({
111
+ type: z.enum(MANUAL_GATE_RESUME_CHECK_TYPES),
112
+ name: z.string(),
113
+ command: z.array(z.string()),
114
+ cwd: z.enum(MANUAL_GATE_RESUME_CHECK_CWDS).nullable(),
115
+ timeoutMs: z.number().nullable(),
116
+ });
117
+ const manualGateSchema = z.object({
118
+ id: z.string(),
119
+ title: z.string(),
120
+ reason: z.string(),
121
+ instructionsMarkdown: z.string(),
122
+ resumeChecks: z.array(manualGateResumeCheckSchema),
123
+ });
124
+ const coderScopePayloadSchema = z.object({
125
+ action: z.enum(CODER_SCOPE_ACTIONS),
126
+ message: z.string(),
127
+ progress: executeScopeProgressSchema,
128
+ manualGate: manualGateSchema.nullable(),
129
+ derivedPlan: z.string(),
130
+ blockedReason: z.string(),
131
+ });
132
+ // Parse-stage view of the coder scope payload. The full definition above is
133
+ // what the emitted JSON Schema promises callers; validation historically only
134
+ // checks object-ness of `progress` and `manualGate` up front, then applies
135
+ // the delimiter-protocol progress messages and the manual-gate deep
136
+ // validation (which trims strings and accepts absent cwd/timeoutMs) as
137
+ // explicit post-parse steps with their own pinned message grammar.
138
+ const coderScopeParseSchema = coderScopePayloadSchema.extend({
139
+ progress: z.looseObject({}),
140
+ manualGate: z.looseObject({}).nullable(),
141
+ });
142
+ const finalCompletionSummaryPayloadSchema = z.object({
143
+ planGoalSatisfied: z.boolean(),
144
+ whatChangedOverall: z.string(),
145
+ verificationSummary: z.string(),
146
+ remainingKnownGaps: z.array(z.string()),
147
+ });
148
+ const squashCommitMessageDraftSchema = z.object({
149
+ subject: z.string().describe(SQUASH_COMMIT_SUBJECT_DESCRIPTION),
150
+ bullets: z.array(z.string()).min(2).max(5).describe(SQUASH_COMMIT_BULLETS_DESCRIPTION),
151
+ });
152
+ const finalCompletionReviewerPayloadSchema = z.object({
153
+ action: z.enum(FINAL_COMPLETION_REVIEWER_ACTIONS),
154
+ summary: z.string(),
155
+ rationale: z.string(),
156
+ missingWork: z
157
+ .object({
158
+ summary: z.string(),
159
+ requiredOutcome: z.string(),
160
+ verification: z.string(),
161
+ })
162
+ .nullable(),
163
+ squashCommitMessage: squashCommitMessageDraftSchema.nullable().describe(SQUASH_COMMIT_MESSAGE_DESCRIPTION),
164
+ });
165
+ // Parse-stage view of the final-completion reviewer verdict. The squash draft
166
+ // is advisory: a malformed or absent draft must never reject the verdict, so
167
+ // validation only checks object-ness here and routes the draft through
168
+ // validate/repair in parseFinalCompletionReviewerPayload.
169
+ const finalCompletionReviewerParseSchema = finalCompletionReviewerPayloadSchema.extend({
170
+ squashCommitMessage: z.looseObject({}).nullable().optional(),
171
+ });
172
+ // Canonical key order of the previous hand-written builders. Emitted schemas
173
+ // are serialized into prompt text with JSON.stringify(schema, null, 2), so
174
+ // this ordering changes prompt bytes and is pinned byte-for-byte by
175
+ // test/agent-payload-schemas.test.ts.
176
+ const EMITTED_SCHEMA_KEY_ORDER = [
177
+ 'description',
178
+ 'type',
179
+ 'enum',
180
+ 'minItems',
181
+ 'maxItems',
182
+ 'items',
183
+ 'properties',
184
+ 'required',
185
+ 'additionalProperties',
186
+ ];
187
+ function isJsonSchemaRecord(value) {
188
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
189
+ }
190
+ // Collapse zod's `anyOf: [X, { type: 'null' }]` encoding of .nullable() into
191
+ // the hand-written builders' merged form: X with `type: [<X type>, 'null']`,
192
+ // appending null to X's enum when one is present (e.g. the resume-check cwd).
193
+ function collapseNullableAnyOf(node) {
194
+ const { anyOf, ...rest } = node;
195
+ if (!Array.isArray(anyOf) || anyOf.length !== 2) {
196
+ return node;
197
+ }
198
+ const [base, nullBranch] = anyOf;
199
+ if (!isJsonSchemaRecord(base) || !isJsonSchemaRecord(nullBranch)) {
200
+ return node;
201
+ }
202
+ if (nullBranch.type !== 'null' || Object.keys(nullBranch).length !== 1) {
203
+ return node;
204
+ }
205
+ const merged = { ...base, ...rest };
206
+ merged.type = Array.isArray(base.type) ? [...base.type, 'null'] : [base.type, 'null'];
207
+ if (Array.isArray(base.enum)) {
208
+ merged.enum = [...base.enum, null];
209
+ }
210
+ return merged;
211
+ }
212
+ function normalizeEmittedSchemaNode(node) {
213
+ const record = collapseNullableAnyOf(node);
214
+ const keys = Object.keys(record);
215
+ const orderedKeys = [
216
+ ...EMITTED_SCHEMA_KEY_ORDER.filter((key) => keys.includes(key)),
217
+ ...keys.filter((key) => !EMITTED_SCHEMA_KEY_ORDER.includes(key)),
218
+ ];
219
+ const normalized = {};
220
+ for (const key of orderedKeys) {
221
+ const value = record[key];
222
+ if (key === 'items' && isJsonSchemaRecord(value)) {
223
+ normalized[key] = normalizeEmittedSchemaNode(value);
224
+ }
225
+ else if (key === 'properties' && isJsonSchemaRecord(value)) {
226
+ const properties = {};
227
+ for (const [propertyName, propertySchema] of Object.entries(value)) {
228
+ properties[propertyName] = isJsonSchemaRecord(propertySchema)
229
+ ? normalizeEmittedSchemaNode(propertySchema)
230
+ : propertySchema;
231
+ }
232
+ normalized[key] = properties;
233
+ }
234
+ else {
235
+ normalized[key] = value;
236
+ }
237
+ }
238
+ const type = normalized.type;
239
+ const isObjectNode = type === 'object' || (Array.isArray(type) && type.includes('object'));
240
+ if (isObjectNode && normalized.properties !== undefined) {
241
+ normalized.additionalProperties = false;
242
+ }
243
+ return normalized;
244
+ }
245
+ // The declared literal return shape is asserted here rather than proven
246
+ // structurally: the byte-level serialization pins in
247
+ // test/agent-payload-schemas.test.ts fail whenever the zod-derived emission
248
+ // diverges from the declared shapes.
249
+ function emitJsonSchema(schema) {
250
+ const emitted = z.toJSONSchema(schema);
251
+ delete emitted.$schema;
252
+ return normalizeEmittedSchemaNode(emitted);
253
+ }
18
254
  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
- };
255
+ return emitJsonSchema(reviewerPayloadSchema);
44
256
  }
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.');
257
+ export function buildPlanReviewerSchema() {
258
+ return emitJsonSchema(planReviewerPayloadSchema);
259
+ }
260
+ export function buildBlockedAdjudicatorSchema() {
261
+ return emitJsonSchema(blockedAdjudicatorPayloadSchema);
262
+ }
263
+ export function buildCoderResponseSchema() {
264
+ return emitJsonSchema(coderResponsePayloadSchema);
265
+ }
266
+ export function buildCoderBlockedRecoveryDispositionSchema() {
267
+ return emitJsonSchema(coderBlockedRecoveryDispositionPayloadSchema);
268
+ }
269
+ export function buildCoderPlanResponseSchema() {
270
+ return emitJsonSchema(coderPlanResponsePayloadSchema);
271
+ }
272
+ export function buildCoderPlanSchema() {
273
+ return emitJsonSchema(coderPlanPayloadSchema);
274
+ }
275
+ export function buildCoderScopeSchema() {
276
+ return emitJsonSchema(coderScopePayloadSchema);
277
+ }
278
+ export function buildExecuteScopeProgressSchema() {
279
+ return emitJsonSchema(executeScopeProgressSchema);
280
+ }
281
+ export function buildFinalCompletionSummarySchema() {
282
+ return emitJsonSchema(finalCompletionSummaryPayloadSchema);
283
+ }
284
+ export function buildFinalCompletionReviewerSchema() {
285
+ return emitJsonSchema(finalCompletionReviewerPayloadSchema);
286
+ }
287
+ // --- sequential zod-backed validation ------------------------------------------
288
+ //
289
+ // The historical validators were strictly sequential: each field was read and
290
+ // checked one at a time in a fixed order, throwing at the FIRST failure, so a
291
+ // property later in the validation order was never read once an earlier check
292
+ // failed — a later throwing accessor could not mask an earlier error. A
293
+ // whole-payload safeParse instead reads every field eagerly to collect all
294
+ // issues. The traversal below therefore keeps the historical sequential read
295
+ // order and short-circuit semantics while zod performs the actual
296
+ // validation: each field or array element is parsed by its own zod
297
+ // sub-schema at the point the traversal reaches it, and the first zod issue
298
+ // is translated through ONE shared formatter into the historical grammar:
299
+ // `<Label>[.<path>] must be a non-null object.` / `... must be a string.` /
300
+ // `... must be a boolean.` / `... must be an array.` /
301
+ // `... must be exactly one of: a, b.`
302
+ function describeSchemaIssue(fieldPath, issue) {
303
+ if (issue.code === 'invalid_type') {
304
+ switch (issue.expected) {
305
+ case 'object':
306
+ return `${fieldPath} must be a non-null object.`;
307
+ case 'string':
308
+ return `${fieldPath} must be a string.`;
309
+ case 'boolean':
310
+ return `${fieldPath} must be a boolean.`;
311
+ case 'array':
312
+ return `${fieldPath} must be an array.`;
313
+ default:
314
+ break;
315
+ }
50
316
  }
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
- };
317
+ if (issue.code === 'invalid_value') {
318
+ return `${fieldPath} must be exactly one of: ${issue.values.join(', ')}.`;
319
+ }
320
+ // Any other zod constraint surfaces with zod's own issue description.
321
+ return `${fieldPath} failed validation: ${issue.message}`;
57
322
  }
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.`);
323
+ function firstSchemaIssueMessage(schema, value, fieldPath) {
324
+ const result = schema.safeParse(value);
325
+ if (result.success) {
326
+ // Unreachable: callers invoke this only for values the schema rejects.
327
+ return `${fieldPath} failed validation.`;
64
328
  }
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
- };
329
+ return describeSchemaIssue(fieldPath, result.error.issues[0]);
330
+ }
331
+ // Container gates: return the value when it has the container type the
332
+ // schema demands; otherwise derive the failure message from the schema's own
333
+ // zod issue. zod fails fast on container-type mismatches without reading any
334
+ // property or element, so no accessor is invoked for a rejected value, and
335
+ // the gate lets the traversal defer member reads to their historical
336
+ // positions instead of letting a whole-container parse read them eagerly.
337
+ function requireRecordSequential(schema, value, fieldPath) {
338
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
339
+ return value;
340
+ }
341
+ throw new Error(firstSchemaIssueMessage(schema, value, fieldPath));
72
342
  }
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.`);
343
+ function requireArraySequential(schema, value, fieldPath) {
344
+ if (Array.isArray(value)) {
345
+ return value;
76
346
  }
77
- return value;
347
+ throw new Error(firstSchemaIssueMessage(schema, value, fieldPath));
78
348
  }
79
- function requireReviewerProperties(value, requiredKeys, label) {
80
- for (const key of requiredKeys) {
81
- if (!Object.prototype.hasOwnProperty.call(value, key)) {
349
+ function validateFieldSequential(fieldSchema, value, fieldPath) {
350
+ if (fieldSchema instanceof z.ZodOptional) {
351
+ if (value === undefined) {
352
+ return undefined;
353
+ }
354
+ return validateFieldSequential(fieldSchema.unwrap(), value, fieldPath);
355
+ }
356
+ if (fieldSchema instanceof z.ZodNullable) {
357
+ if (value === null) {
358
+ return null;
359
+ }
360
+ return validateFieldSequential(fieldSchema.unwrap(), value, fieldPath);
361
+ }
362
+ if (fieldSchema instanceof z.ZodArray) {
363
+ const items = requireArraySequential(fieldSchema, value, fieldPath);
364
+ const element = fieldSchema.element;
365
+ if (element instanceof z.ZodObject) {
366
+ // Historical object-array semantics: every item's object-ness is
367
+ // checked before any item's fields are read.
368
+ const records = items.map((item, index) => requireRecordSequential(element, item, `${fieldPath}[${index}]`));
369
+ return records.map((item, index) => walkObjectSequential(element, item, `${fieldPath}[${index}]`));
370
+ }
371
+ return items.map((item, index) => validateFieldSequential(element, item, `${fieldPath}[${index}]`));
372
+ }
373
+ if (fieldSchema instanceof z.ZodObject) {
374
+ const record = requireRecordSequential(fieldSchema, value, fieldPath);
375
+ // Empty-shape loose objects are the historical object-ness-only
376
+ // passthroughs (coder-scope progress/manualGate, the squash draft): the
377
+ // ORIGINAL reference is returned so the explicit post-parse steps read
378
+ // fields at their historical positions.
379
+ if (Object.keys(fieldSchema.shape).length === 0) {
380
+ return record;
381
+ }
382
+ return walkObjectSequential(fieldSchema, record, fieldPath);
383
+ }
384
+ // Leaf schema: zod itself enforces the constraint at the point the
385
+ // traversal reaches this field; its first issue is translated through the
386
+ // shared formatter.
387
+ const result = fieldSchema.safeParse(value);
388
+ if (!result.success) {
389
+ throw new Error(describeSchemaIssue(fieldPath, result.error.issues[0]));
390
+ }
391
+ return result.data;
392
+ }
393
+ // Walks a strip-mode object schema field-by-field in shape declaration order,
394
+ // reading each property only when its turn comes and throwing at the first
395
+ // failure. Unknown keys are omitted from the rebuilt output (historical
396
+ // permissive behavior).
397
+ function walkObjectSequential(schema, record, basePath) {
398
+ const output = {};
399
+ for (const [key, fieldSchema] of Object.entries(schema.shape)) {
400
+ const fieldPath = `${basePath}.${key}`;
401
+ // Historical optional/nullable field patterns performed one property
402
+ // read for the undefined/null test and a FRESH read for validation when
403
+ // the field was present, so stateful accessors observe both reads (the
404
+ // blocked-adjudicator targetCanonicalIds ternary and the coder-scope
405
+ // manualGate ternary behaved this way).
406
+ if (fieldSchema instanceof z.ZodOptional) {
407
+ if (record[key] === undefined) {
408
+ continue;
409
+ }
410
+ output[key] = validateFieldSequential(fieldSchema.unwrap(), record[key], fieldPath);
411
+ continue;
412
+ }
413
+ if (fieldSchema instanceof z.ZodNullable) {
414
+ if (record[key] === null) {
415
+ output[key] = null;
416
+ continue;
417
+ }
418
+ output[key] = validateFieldSequential(fieldSchema.unwrap(), record[key], fieldPath);
419
+ continue;
420
+ }
421
+ output[key] = validateFieldSequential(fieldSchema, record[key], fieldPath);
422
+ }
423
+ return output;
424
+ }
425
+ // Permissive-family entry point: object-ness first (`<Label> must be a
426
+ // non-null object.`), then the sequential field walk in shape declaration
427
+ // order, which matches the historical field-by-field check order of these
428
+ // validators.
429
+ function parsePayload(schema, payload, label) {
430
+ if (!(schema instanceof z.ZodObject)) {
431
+ throw new Error(`${label} failed validation: sequential parsing requires an object schema.`);
432
+ }
433
+ const record = requireRecordSequential(schema, payload, label);
434
+ return walkObjectSequential(schema, record, label);
435
+ }
436
+ // --- reviewer payload (strict unknown-key family) ----------------------------
437
+ // The strict reviewer family additionally enforced, at each object level and
438
+ // BEFORE reading any declared field: required own properties (in key order,
439
+ // via Object.prototype.hasOwnProperty — prototype-inherited properties are
440
+ // rejected and their accessors never invoked) and unknown-key rejection (in
441
+ // input order, via Object.keys). The historical sequence, reproduced below:
442
+ // object-ness, missing required own properties, unknown properties, findings
443
+ // array-ness, summary, each finding in order (object-ness, missing, unknown,
444
+ // files array-ness, severity, files items, claim, evidence, requiredAction),
445
+ // then meaningfulProgressAction and meaningfulProgressRationale.
446
+ function assertReviewerOwnProperties(record, shape, label) {
447
+ for (const key of Object.keys(shape)) {
448
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
82
449
  throw new Error(`${label} is missing required property "${key}".`);
83
450
  }
84
451
  }
85
- for (const key of Object.keys(value)) {
86
- if (!requiredKeys.includes(key)) {
452
+ for (const key of Object.keys(record)) {
453
+ if (!Object.prototype.hasOwnProperty.call(shape, key)) {
87
454
  throw new Error(`${label} included unknown property "${key}".`);
88
455
  }
89
456
  }
90
457
  }
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(', ')}.`);
458
+ function validateReviewerFindingSequential(finding, index) {
459
+ const label = `Reviewer payload.findings[${index}]`;
460
+ const record = requireRecordSequential(reviewerFindingSchema, finding, label);
461
+ const shape = reviewerFindingSchema.shape;
462
+ assertReviewerOwnProperties(record, shape, label);
463
+ // Historical order and access pattern: the files array-ness check read
464
+ // `files` before severity (or any other declared field) was read, and the
465
+ // items mapping below RE-READS the property after severity, so a stateful
466
+ // accessor's second read supplies the validated items.
467
+ const filesProbe = record.files;
468
+ if (!Array.isArray(filesProbe)) {
469
+ throw new Error(firstSchemaIssueMessage(shape.files, filesProbe, `${label}.files`));
100
470
  }
101
- return value;
102
- }
103
- export function buildPlanReviewerSchema() {
104
471
  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,
472
+ severity: validateFieldSequential(shape.severity, record.severity, `${label}.severity`),
473
+ files: record.files.map((file, fileIndex) => validateFieldSequential(shape.files.element, file, `${label}.files[${fileIndex}]`)),
474
+ claim: validateFieldSequential(shape.claim, record.claim, `${label}.claim`),
475
+ evidence: validateFieldSequential(shape.evidence, record.evidence, `${label}.evidence`),
476
+ requiredAction: validateFieldSequential(shape.requiredAction, record.requiredAction, `${label}.requiredAction`),
126
477
  };
127
478
  }
128
- export function buildBlockedAdjudicatorSchema() {
479
+ export function validateReviewerPayload(payload) {
480
+ const record = requireRecordSequential(reviewerPayloadSchema, payload, 'Reviewer payload');
481
+ const shape = reviewerPayloadSchema.shape;
482
+ assertReviewerOwnProperties(record, shape, 'Reviewer payload');
483
+ // Historical access pattern: the array-ness check performs its own read,
484
+ // and the mapping below re-reads the property (after summary), so a
485
+ // stateful accessor's second read is the one validated and normalized.
486
+ const findingsProbe = record.findings;
487
+ if (!Array.isArray(findingsProbe)) {
488
+ throw new Error(firstSchemaIssueMessage(shape.findings, findingsProbe, 'Reviewer payload.findings'));
489
+ }
129
490
  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,
491
+ summary: validateFieldSequential(shape.summary, record.summary, 'Reviewer payload.summary'),
492
+ findings: record.findings.map((finding, index) => validateReviewerFindingSequential(finding, index)),
493
+ meaningfulProgressAction: validateFieldSequential(shape.meaningfulProgressAction, record.meaningfulProgressAction, 'Reviewer payload.meaningfulProgressAction'),
494
+ meaningfulProgressRationale: validateFieldSequential(shape.meaningfulProgressRationale, record.meaningfulProgressRationale, 'Reviewer payload.meaningfulProgressRationale'),
143
495
  };
144
496
  }
497
+ // --- permissive validators ----------------------------------------------------
145
498
  // Validates a read-only review_stuck arbiter verdict. Exactly one triage
146
499
  // category is autonomously recoverable: `misunderstanding` requires
147
500
  // recoverable=true plus a non-empty resolutionDirective; the three genuine-wall
@@ -150,22 +503,15 @@ export function buildBlockedAdjudicatorSchema() {
150
503
  // triage, or recoverable=false paired with `misunderstanding`) is rejected so a
151
504
  // malformed verdict can never drive an autonomous recovery.
152
505
  export function validateBlockedAdjudicatorVerdictPayload(rawPayload) {
153
- const value = requirePayloadObject(rawPayload, 'Review-stuck arbiter payload');
506
+ const parsed = parsePayload(blockedAdjudicatorPayloadSchema, rawPayload, 'Review-stuck arbiter payload');
154
507
  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'),
508
+ recoverable: parsed.recoverable,
509
+ triageCategory: parsed.triageCategory,
510
+ resolutionDirective: parsed.resolutionDirective,
163
511
  // The verdict's targetCanonicalIds is optional: a coder/split-plan block has
164
512
  // 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'),
513
+ targetCanonicalIds: parsed.targetCanonicalIds ?? [],
514
+ rationale: parsed.rationale,
169
515
  };
170
516
  if (!payload.rationale.trim()) {
171
517
  throw new Error('Review-stuck arbiter returned an empty rationale.');
@@ -184,222 +530,21 @@ export function validateBlockedAdjudicatorVerdictPayload(rawPayload) {
184
530
  return payload;
185
531
  }
186
532
  export function validatePlanReviewerPayload(payload) {
187
- const value = requirePayloadObject(payload, 'Plan reviewer payload');
533
+ const parsed = parsePayload(planReviewerPayloadSchema, payload, 'Plan reviewer payload');
188
534
  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`),
535
+ summary: parsed.summary,
536
+ executionShape: parsed.executionShape,
537
+ findings: parsed.findings.map((finding) => ({
538
+ severity: finding.severity,
539
+ files: finding.files,
540
+ claim: finding.claim,
541
+ // Plan-review findings carry no evidence field; the normalized finding
542
+ // always forces an empty string.
202
543
  evidence: '',
203
- requiredAction: requirePayloadString(finding.requiredAction, `Plan reviewer payload.findings[${index}].requiredAction`),
544
+ requiredAction: finding.requiredAction,
204
545
  })),
205
546
  };
206
547
  }
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
548
  function parseJsonPayload(raw, label) {
404
549
  try {
405
550
  return JSON.parse(raw);
@@ -435,54 +580,15 @@ function extractDelimitedPayload(raw, label) {
435
580
  };
436
581
  }
437
582
  function requireNonEmptyString(value, field, label) {
438
- if (typeof value !== 'string' || value.trim().length === 0) {
583
+ // Base string-ness is enforced by the field's zod node from
584
+ // executeScopeProgressSchema; the non-empty rule, trimming, and the pinned
585
+ // delimiter-protocol message (shared by missing, wrong-typed, and blank
586
+ // values) are runtime semantics layered on top.
587
+ const parsed = executeScopeProgressSchema.shape[field].safeParse(value);
588
+ if (!parsed.success || parsed.data.trim().length === 0) {
439
589
  throw new Error(`${label} returned an empty or missing ${field} field in the progress-justification payload.`);
440
590
  }
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
- };
591
+ return parsed.data.trim();
486
592
  }
487
593
  // Legacy marker-protocol compatibility for active legacy_marker_v1 execution
488
594
  // sessions. New primary execution sessions use CoderScopePayload instead.
@@ -507,18 +613,7 @@ export function stripExecuteScopeProgressPayload(raw) {
507
613
  return before || after;
508
614
  }
509
615
  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
- };
616
+ const payload = parsePayload(coderResponsePayloadSchema, rawPayload, 'Coder response round payload');
522
617
  const derivedPlan = payload.derivedPlan?.trim() ?? '';
523
618
  if (payload.outcome === 'split_plan' && !derivedPlan) {
524
619
  throw new Error('Coder response round returned outcome=split_plan without a derivedPlan payload.');
@@ -529,33 +624,10 @@ export function validateCoderResponsePayload(rawPayload) {
529
624
  return payload;
530
625
  }
531
626
  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
- };
627
+ return parsePayload(coderPlanResponsePayloadSchema, payload, 'Planner plan-response round payload');
542
628
  }
543
629
  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
- };
630
+ const payload = parsePayload(coderPlanPayloadSchema, rawPayload, 'Planner plan round payload');
559
631
  const planBody = payload.planBody.trim();
560
632
  const blockedReason = payload.blockedReason.trim();
561
633
  if (payload.action === 'ready_for_review' && !planBody) {
@@ -573,23 +645,14 @@ export function validateCoderPlanPayload(rawPayload) {
573
645
  };
574
646
  }
575
647
  export function validateCoderScopePayload(rawPayload) {
576
- const value = requirePayloadObject(rawPayload, 'Coder scope round payload');
648
+ const parsed = parsePayload(coderScopeParseSchema, rawPayload, 'Coder scope round payload');
577
649
  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'),
650
+ action: parsed.action,
651
+ message: parsed.message,
652
+ progress: parsed.progress,
653
+ manualGate: parsed.manualGate,
654
+ derivedPlan: parsed.derivedPlan,
655
+ blockedReason: parsed.blockedReason,
593
656
  };
594
657
  const progress = {
595
658
  milestoneTargeted: requireNonEmptyString(payload.progress?.milestoneTargeted, 'milestoneTargeted', 'Coder scope round'),
@@ -622,10 +685,10 @@ export function validateCoderScopePayload(rawPayload) {
622
685
  if (blockedReason) {
623
686
  throw new Error('Coder scope round returned a blockedReason payload with action=manual_gate.');
624
687
  }
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');
688
+ const id = requireNonEmptyManualGateString(manualGateSchema.shape.id, manualGate.id, 'manualGate.id');
689
+ const title = requireNonEmptyManualGateString(manualGateSchema.shape.title, manualGate.title, 'manualGate.title');
690
+ const reason = requireNonEmptyManualGateString(manualGateSchema.shape.reason, manualGate.reason, 'manualGate.reason');
691
+ const instructionsMarkdown = requireNonEmptyManualGateString(manualGateSchema.shape.instructionsMarkdown, manualGate.instructionsMarkdown, 'manualGate.instructionsMarkdown');
629
692
  const resumeChecks = validateManualGateResumeChecks(manualGate.resumeChecks);
630
693
  return {
631
694
  ...payload,
@@ -649,39 +712,72 @@ export function validateCoderScopePayload(rawPayload) {
649
712
  progress,
650
713
  };
651
714
  }
652
- function requireNonEmptyManualGateString(value, fieldPath) {
653
- if (typeof value !== 'string' || value.trim().length === 0) {
715
+ // Base-constraint nodes for the resume-check cwd/timeoutMs fields, unwrapped
716
+ // from the emitted schema's nullable wrappers. Runtime optionality
717
+ // intentionally differs from the emitted schema's requiredness (the runtime
718
+ // validator accepts absence where the emitted schema requires the key), and
719
+ // the historical validator performed ONE property read per comparison and
720
+ // re-read the property for the normalized output — stateful accessors
721
+ // legitimately observe multiple short-circuited reads. The membership
722
+ // literals are therefore destructured from the zod enum node (one comparison
723
+ // per option, per read, as historically), and number-ness parses through the
724
+ // zod number node at its historical read position.
725
+ const resumeCheckCwdOptions = manualGateResumeCheckSchema.shape.cwd.unwrap().options;
726
+ const resumeCheckTimeoutMsNumberSchema = manualGateResumeCheckSchema.shape.timeoutMs.unwrap();
727
+ function requireNonEmptyManualGateString(fieldSchema, value, fieldPath) {
728
+ // Base string-ness is enforced by the field's zod node; the non-empty rule,
729
+ // trimming, and the pinned message (shared by missing, wrong-typed, and
730
+ // blank values) are runtime semantics layered on top.
731
+ const parsed = fieldSchema.safeParse(value);
732
+ if (!parsed.success || typeof parsed.data !== 'string' || parsed.data.trim().length === 0) {
654
733
  throw new Error(`Coder scope round returned an empty or missing ${fieldPath} field.`);
655
734
  }
656
- return value.trim();
735
+ return parsed.data.trim();
657
736
  }
658
737
  function validateManualGateResumeChecks(value) {
738
+ // The at-least-one-command rule is runtime-only semantics (the emitted
739
+ // schema does not constrain resumeChecks length), and its pinned message
740
+ // covers the array-ness base constraint too.
659
741
  if (!Array.isArray(value) || value.length === 0) {
660
742
  throw new Error('Coder scope round returned manualGate.resumeChecks without at least one command check.');
661
743
  }
744
+ const checkShape = manualGateResumeCheckSchema.shape;
662
745
  return value.map((check, index) => {
663
746
  const fieldPath = `manualGate.resumeChecks[${index}]`;
747
+ // Historical container gate with its pinned message; array items
748
+ // intentionally fall through to the type check below, exactly as the
749
+ // old typeof-based gate behaved.
664
750
  if (check === null || typeof check !== 'object') {
665
751
  throw new Error(`Coder scope round returned invalid ${fieldPath}.`);
666
752
  }
667
753
  const candidate = check;
668
- if (candidate.type !== 'command') {
754
+ if (!checkShape.type.safeParse(candidate.type).success) {
669
755
  throw new Error(`Coder scope round returned ${fieldPath}.type that is not "command".`);
670
756
  }
671
- const name = requireNonEmptyManualGateString(candidate.name, `${fieldPath}.name`);
757
+ const name = requireNonEmptyManualGateString(checkShape.name, candidate.name, `${fieldPath}.name`);
758
+ // Emptiness is runtime-only semantics with a pinned message that also
759
+ // covers array-ness; each part's base string-ness routes through the
760
+ // command element's zod node below.
672
761
  if (!Array.isArray(candidate.command) || candidate.command.length === 0) {
673
762
  throw new Error(`Coder scope round returned ${fieldPath}.command without a non-empty string array.`);
674
763
  }
675
- const command = candidate.command.map((part, commandIndex) => requireNonEmptyManualGateString(part, `${fieldPath}.command[${commandIndex}]`));
764
+ const command = candidate.command.map((part, commandIndex) => requireNonEmptyManualGateString(checkShape.command.element, part, `${fieldPath}.command[${commandIndex}]`));
765
+ // Historical access semantics preserved exactly: one property read per
766
+ // comparison with short-circuiting, and fresh reads for the normalized
767
+ // output below. The undefined/null comparisons carry the runtime
768
+ // optionality; membership tests one zod enum option per read; the
769
+ // positive-safe-integer rule stays explicit runtime semantics.
676
770
  if (candidate.cwd !== undefined &&
677
771
  candidate.cwd !== null &&
678
- candidate.cwd !== 'repo' &&
679
- candidate.cwd !== 'run_dir') {
772
+ candidate.cwd !== resumeCheckCwdOptions[0] &&
773
+ candidate.cwd !== resumeCheckCwdOptions[1]) {
680
774
  throw new Error(`Coder scope round returned ${fieldPath}.cwd that is not "repo" or "run_dir".`);
681
775
  }
682
776
  if (candidate.timeoutMs !== undefined &&
683
777
  candidate.timeoutMs !== null &&
684
- (typeof candidate.timeoutMs !== 'number' || !Number.isSafeInteger(candidate.timeoutMs) || candidate.timeoutMs < 1)) {
778
+ (!resumeCheckTimeoutMsNumberSchema.safeParse(candidate.timeoutMs).success ||
779
+ !Number.isSafeInteger(candidate.timeoutMs) ||
780
+ candidate.timeoutMs < 1)) {
685
781
  throw new Error(`Coder scope round returned ${fieldPath}.timeoutMs that is not a positive safe integer.`);
686
782
  }
687
783
  return {
@@ -693,23 +789,8 @@ function validateManualGateResumeChecks(value) {
693
789
  };
694
790
  });
695
791
  }
696
- function nonEmptyTrimmedItems(items) {
697
- return items.map((item) => item.trim()).filter((item) => item.length > 0);
698
- }
699
792
  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
- };
793
+ const payload = parsePayload(coderBlockedRecoveryDispositionPayloadSchema, rawPayload, 'Coder blocked-recovery payload');
713
794
  const blocker = payload.blocker.trim();
714
795
  const replacementPlan = payload.replacementPlan.trim();
715
796
  if (payload.action === 'replace_current_scope' && !replacementPlan) {
@@ -724,13 +805,7 @@ export function validateCoderBlockedRecoveryDispositionPayload(rawPayload) {
724
805
  return payload;
725
806
  }
726
807
  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
- };
808
+ const payload = parsePayload(finalCompletionSummaryPayloadSchema, rawPayload, 'Final completion summary payload');
734
809
  const whatChangedOverall = payload.whatChangedOverall.trim();
735
810
  const verificationSummary = payload.verificationSummary.trim();
736
811
  const remainingKnownGaps = payload.remainingKnownGaps
@@ -756,37 +831,41 @@ export function parseFinalCompletionSummaryPayload(rawPayload) {
756
831
  };
757
832
  }
758
833
  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
834
+ // Historical sequential order (pinned by test/agent-payload-schemas.test.ts,
835
+ // a deliberate deviation from shape order): action, missingWork
836
+ // object-ness, summary, rationale, missingWork nested fields, then
837
+ // squashCommitMessage object-ness. Each step short-circuits before any
838
+ // later property is read.
839
+ const label = 'Final completion reviewer verdict payload';
840
+ const record = requireRecordSequential(finalCompletionReviewerParseSchema, rawPayload, label);
841
+ const shape = finalCompletionReviewerParseSchema.shape;
842
+ const missingWorkSchema = finalCompletionReviewerPayloadSchema.shape.missingWork.unwrap();
843
+ const action = validateFieldSequential(shape.action, record.action, `${label}.action`);
844
+ const missingWorkValue = record.missingWork;
845
+ const missingWorkRecord = missingWorkValue === null
767
846
  ? null
768
- : requirePayloadObject(missingWorkValue, 'Final completion reviewer verdict payload.missingWork');
847
+ : requireRecordSequential(missingWorkSchema, missingWorkValue, `${label}.missingWork`);
848
+ const summaryValue = validateFieldSequential(shape.summary, record.summary, `${label}.summary`);
849
+ const rationaleValue = validateFieldSequential(shape.rationale, record.rationale, `${label}.rationale`);
769
850
  const payload = {
770
851
  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
852
+ summary: summaryValue,
853
+ rationale: rationaleValue,
854
+ missingWork: missingWorkRecord === null
774
855
  ? 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
856
+ : walkObjectSequential(missingWorkSchema, missingWorkRecord, `${label}.missingWork`),
857
+ // Historical access pattern: the null test, the undefined test, and the
858
+ // object-ness validation each read the property (three reads for a
859
+ // present draft, short-circuiting for null). Only object-ness is checked
860
+ // here — via the parse-variant zod node — because the advisory draft
861
+ // routes through the validate/repair post-parse step below.
862
+ squashCommitMessage: (record.squashCommitMessage === null || record.squashCommitMessage === undefined
781
863
  ? null
782
- : requirePayloadObject(value.squashCommitMessage, 'Final completion reviewer verdict payload.squashCommitMessage'),
864
+ : requireRecordSequential(shape.squashCommitMessage, record.squashCommitMessage, `${label}.squashCommitMessage`)),
783
865
  };
784
866
  const summary = payload.summary.trim();
785
867
  const rationale = payload.rationale.trim();
786
868
  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
869
  if (!summary) {
791
870
  throw new Error('Final completion reviewer verdict returned an empty summary field.');
792
871
  }
@@ -818,7 +897,7 @@ export function parseFinalCompletionReviewerPayload(rawPayload) {
818
897
  label: 'Final completion reviewer squashCommitMessage',
819
898
  });
820
899
  }
821
- catch (error) {
900
+ catch {
822
901
  squashCommitMessage = repairReviewerSquashMessageDraft(squashCommitMessageValue);
823
902
  }
824
903
  }