@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.
- package/README.md +206 -209
- package/SECURITY.md +16 -19
- package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
- package/dist/neal/adjudicator/execute.js +33 -33
- package/dist/neal/adjudicator/final-completion.js +2 -31
- package/dist/neal/adjudicator/planning.js +264 -24
- package/dist/neal/agents/prompts.js +7 -15
- package/dist/neal/agents/rounds.js +51 -73
- package/dist/neal/agents/schemas.js +615 -502
- package/dist/neal/agents/structured-json.js +36 -0
- package/dist/neal/blocked-guidance.js +16 -9
- package/dist/neal/cli.js +2 -2
- package/dist/neal/commands/check.js +2 -2
- package/dist/neal/commands/compat.js +100 -36
- package/dist/neal/commands/interactive-activity.js +2 -2
- package/dist/neal/commands/recovery-guidance.js +20 -8
- package/dist/neal/commands/runtime.js +4 -2
- package/dist/neal/config.js +40 -7
- package/dist/neal/context/inline-review-context.js +13 -28
- package/dist/neal/context/reviewer-context.js +19 -14
- package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
- package/dist/neal/eval/reviewer-eval.js +155 -0
- package/dist/neal/git.js +9 -3
- package/dist/neal/interactive-controls.js +5 -0
- package/dist/neal/orchestrator/completion.js +215 -113
- package/dist/neal/orchestrator/failures.js +7 -0
- package/dist/neal/orchestrator/notifications.js +9 -9
- package/dist/neal/orchestrator/phases/planning.js +124 -112
- package/dist/neal/orchestrator/phases/recovery.js +151 -115
- package/dist/neal/orchestrator/phases/review.js +28 -1
- package/dist/neal/orchestrator/split-plan.js +12 -11
- package/dist/neal/orchestrator/transitions.js +36 -71
- package/dist/neal/orchestrator.js +3 -0
- package/dist/neal/plan-doc.js +24 -1
- package/dist/neal/plan-queue.js +29 -1
- package/dist/neal/prompts/assert-builder.js +8 -1
- package/dist/neal/prompts/execute.js +11 -19
- package/dist/neal/prompts/planning.js +64 -55
- package/dist/neal/prompts/review-doctrine.js +13 -50
- package/dist/neal/prompts/specialized.js +28 -22
- package/dist/neal/prompts/specs.js +223 -17
- package/dist/neal/providers/agent-settings-isolation.js +35 -0
- package/dist/neal/providers/anthropic-claude.js +309 -248
- package/dist/neal/providers/detection.js +3 -7
- package/dist/neal/providers/git-config-isolation.js +83 -0
- package/dist/neal/providers/openai-codex.js +102 -204
- package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
- package/dist/neal/providers/openai-compatible.js +1079 -403
- package/dist/neal/providers/openrouter-routing.js +68 -0
- package/dist/neal/providers/pricing.js +124 -0
- package/dist/neal/providers/rate-card.js +2275 -0
- package/dist/neal/providers/registry.js +6 -17
- package/dist/neal/providers/telemetry.js +4 -0
- package/dist/neal/providers/types.js +12 -0
- package/dist/neal/resume-decision.js +7 -6
- package/dist/neal/resume-planner.js +9 -0
- package/dist/neal/retrospective.js +36 -7
- package/dist/neal/review-debt.js +30 -0
- package/dist/neal/review-findings/provider.js +17 -29
- package/dist/neal/review.js +17 -1
- package/dist/neal/run-metrics.js +74 -9
- package/dist/neal/state-invariants.js +11 -3
- package/dist/neal/state-views.js +27 -5
- package/dist/neal/state.js +59 -15
- package/dist/neal/status.js +28 -1
- package/dist/neal/support.js +3 -3
- package/docs/README.md +22 -18
- package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
- package/docs/architecture.md +79 -83
- package/docs/automation.md +11 -11
- package/docs/compat.md +91 -61
- package/docs/compatible-models.md +145 -110
- package/docs/demo.md +15 -5
- package/docs/maintenance.md +74 -32
- package/docs/plan-format.md +61 -33
- package/docs/prompt-evals.md +96 -0
- package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
- package/docs/providers.md +330 -283
- package/docs/release.md +43 -40
- package/docs/review-convergence.md +246 -0
- package/docs/state-machine.md +61 -59
- package/docs/storage.md +40 -31
- package/docs/troubleshooting.md +32 -29
- package/examples/compat/add-edit-verify/PLAN.md +2 -0
- package/examples/compat/add-edit-verify/broken.diff +2 -2
- package/examples/compat/add-edit-verify/good.diff +2 -2
- package/examples/compat/add-edit-verify/src/add.js +1 -1
- package/examples/compat/manifest.json +25 -29
- package/examples/compat/sum-grep-edit/PLAN.md +33 -0
- package/examples/compat/sum-grep-edit/broken.diff +12 -0
- package/examples/compat/sum-grep-edit/good.diff +12 -0
- package/examples/compat/sum-grep-edit/package.json +5 -0
- package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
- package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
- package/examples/issue-triage-js/README.md +0 -15
- package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
- package/examples/reviewer-eval/clean-doc/change.diff +9 -0
- package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
- package/examples/reviewer-eval/clean-extract/change.diff +13 -0
- package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
- package/examples/reviewer-eval/dropped-error/change.diff +15 -0
- package/examples/reviewer-eval/manifest.json +45 -0
- package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
- package/examples/reviewer-eval/off-by-one/change.diff +11 -0
- package/{config.yml → neal.yml} +1 -1
- package/package.json +20 -15
- package/dist/neal/providers/generic-agentic.js +0 -1238
- package/docs/comparison.md +0 -105
- package/examples/compat/is-even-add-test/PLAN.md +0 -30
- package/examples/compat/is-even-add-test/broken.diff +0 -11
- package/examples/compat/is-even-add-test/good.diff +0 -11
- package/examples/compat/is-even-add-test/package.json +0 -5
- package/examples/compat/is-even-add-test/src/is-even.js +0 -3
- package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
- package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
- package/examples/compat/reverse-grep-edit/broken.diff +0 -12
- package/examples/compat/reverse-grep-edit/good.diff +0 -12
- package/examples/compat/reverse-grep-edit/package.json +0 -5
- package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
- 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
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
74
|
-
if (
|
|
75
|
-
|
|
357
|
+
function requireArraySequential(schema, value, fieldPath) {
|
|
358
|
+
if (Array.isArray(value)) {
|
|
359
|
+
return value;
|
|
76
360
|
}
|
|
77
|
-
|
|
361
|
+
throw new Error(firstSchemaIssueMessage(schema, value, fieldPath));
|
|
78
362
|
}
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
if (
|
|
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(
|
|
86
|
-
if (!
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
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
|
|
153
|
-
const
|
|
519
|
+
export function validateConsultantVerdictPayload(rawPayload) {
|
|
520
|
+
const parsed = parsePayload(consultantPayloadSchema, rawPayload, 'Consultant payload');
|
|
154
521
|
const payload = {
|
|
155
|
-
recoverable:
|
|
156
|
-
triageCategory:
|
|
157
|
-
|
|
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:
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
547
|
+
const parsed = parsePayload(planReviewerPayloadSchema, payload, 'Plan reviewer payload');
|
|
188
548
|
return {
|
|
189
|
-
summary:
|
|
190
|
-
executionShape:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
682
|
+
const parsed = parsePayload(coderScopeParseSchema, rawPayload, 'Coder scope round payload');
|
|
577
683
|
const payload = {
|
|
578
|
-
action:
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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
|
|
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
|
|
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 !==
|
|
679
|
-
candidate.cwd !==
|
|
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
|
-
(
|
|
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
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
const
|
|
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
|
-
:
|
|
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:
|
|
772
|
-
rationale:
|
|
773
|
-
missingWork:
|
|
886
|
+
summary: summaryValue,
|
|
887
|
+
rationale: rationaleValue,
|
|
888
|
+
missingWork: missingWorkRecord === null
|
|
774
889
|
? null
|
|
775
|
-
: {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
:
|
|
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
|
|
934
|
+
catch {
|
|
822
935
|
squashCommitMessage = repairReviewerSquashMessageDraft(squashCommitMessageValue);
|
|
823
936
|
}
|
|
824
937
|
}
|