loopforge 3.8.3 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +15 -0
  2. package/dist/audit.d.ts +13 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +18 -3
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +1 -2
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +4 -12
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.js +12 -12
  11. package/dist/committed-round.d.ts +48 -23
  12. package/dist/committed-round.d.ts.map +1 -1
  13. package/dist/committed-round.js +214 -149
  14. package/dist/committed-round.js.map +1 -1
  15. package/dist/contract-items.d.ts +3 -2
  16. package/dist/contract-items.d.ts.map +1 -1
  17. package/dist/contract-items.js +18 -7
  18. package/dist/contract-items.js.map +1 -1
  19. package/dist/enforcement-gate.d.ts +15 -5
  20. package/dist/enforcement-gate.d.ts.map +1 -1
  21. package/dist/enforcement-gate.js +60 -159
  22. package/dist/enforcement-gate.js.map +1 -1
  23. package/dist/engine.d.ts.map +1 -1
  24. package/dist/engine.js +20 -14
  25. package/dist/engine.js.map +1 -1
  26. package/dist/evidence-claims.d.ts +6 -5
  27. package/dist/evidence-claims.d.ts.map +1 -1
  28. package/dist/evidence-claims.js +21 -20
  29. package/dist/evidence-claims.js.map +1 -1
  30. package/dist/evidence-provider.d.ts +73 -42
  31. package/dist/evidence-provider.d.ts.map +1 -1
  32. package/dist/evidence-provider.js +437 -138
  33. package/dist/evidence-provider.js.map +1 -1
  34. package/dist/explain.d.ts +18 -3
  35. package/dist/explain.d.ts.map +1 -1
  36. package/dist/explain.js +26 -5
  37. package/dist/explain.js.map +1 -1
  38. package/dist/field-accounting.d.ts +29 -0
  39. package/dist/field-accounting.d.ts.map +1 -0
  40. package/dist/field-accounting.js +132 -0
  41. package/dist/field-accounting.js.map +1 -0
  42. package/dist/index.d.ts +7 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/loop-compiler.d.ts +5 -11
  47. package/dist/loop-compiler.d.ts.map +1 -1
  48. package/dist/loop-compiler.js +30 -90
  49. package/dist/loop-compiler.js.map +1 -1
  50. package/dist/loop-store.d.ts +3 -0
  51. package/dist/loop-store.d.ts.map +1 -1
  52. package/dist/loop-store.js +21 -11
  53. package/dist/loop-store.js.map +1 -1
  54. package/dist/mcp/round-lifecycle.d.ts +30 -2
  55. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  56. package/dist/mcp/round-lifecycle.js +206 -25
  57. package/dist/mcp/round-lifecycle.js.map +1 -1
  58. package/dist/mcp/server.d.ts.map +1 -1
  59. package/dist/mcp/server.js +17 -14
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/mcp/session.d.ts +15 -1
  62. package/dist/mcp/session.d.ts.map +1 -1
  63. package/dist/mcp/session.js +34 -1
  64. package/dist/mcp/session.js.map +1 -1
  65. package/dist/mcp/submission-boundary.d.ts +5 -2
  66. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  67. package/dist/mcp/submission-boundary.js +38 -6
  68. package/dist/mcp/submission-boundary.js.map +1 -1
  69. package/dist/mcp/tools.d.ts.map +1 -1
  70. package/dist/mcp/tools.js +73 -45
  71. package/dist/mcp/tools.js.map +1 -1
  72. package/dist/policy-metrics.d.ts +14 -0
  73. package/dist/policy-metrics.d.ts.map +1 -1
  74. package/dist/policy-metrics.js +53 -1
  75. package/dist/policy-metrics.js.map +1 -1
  76. package/dist/policy.d.ts +1 -3
  77. package/dist/policy.d.ts.map +1 -1
  78. package/dist/policy.js +3 -4
  79. package/dist/policy.js.map +1 -1
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +71 -35
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/protocol.d.ts +186 -19
  84. package/dist/protocol.d.ts.map +1 -1
  85. package/dist/protocol.js +4 -3
  86. package/dist/protocol.js.map +1 -1
  87. package/dist/replay.d.ts +3 -0
  88. package/dist/replay.d.ts.map +1 -1
  89. package/dist/replay.js +46 -7
  90. package/dist/replay.js.map +1 -1
  91. package/dist/round-contract.d.ts +5 -4
  92. package/dist/round-contract.d.ts.map +1 -1
  93. package/dist/round-contract.js +4 -4
  94. package/dist/round-contract.js.map +1 -1
  95. package/dist/round-coordinator.d.ts +11 -1
  96. package/dist/round-coordinator.d.ts.map +1 -1
  97. package/dist/round-coordinator.js +33 -14
  98. package/dist/round-coordinator.js.map +1 -1
  99. package/dist/round-driver.d.ts +6 -1
  100. package/dist/round-driver.d.ts.map +1 -1
  101. package/dist/round-driver.js +3 -0
  102. package/dist/round-driver.js.map +1 -1
  103. package/dist/round-facts.d.ts +3 -2
  104. package/dist/round-facts.d.ts.map +1 -1
  105. package/dist/round-facts.js +4 -4
  106. package/dist/round-facts.js.map +1 -1
  107. package/dist/round-transaction.d.ts +24 -10
  108. package/dist/round-transaction.d.ts.map +1 -1
  109. package/dist/round-transaction.js +83 -19
  110. package/dist/round-transaction.js.map +1 -1
  111. package/dist/self-eval.d.ts +28 -10
  112. package/dist/self-eval.d.ts.map +1 -1
  113. package/dist/self-eval.js +522 -128
  114. package/dist/self-eval.js.map +1 -1
  115. package/dist/submission-diagnostics.d.ts +12 -0
  116. package/dist/submission-diagnostics.d.ts.map +1 -0
  117. package/dist/submission-diagnostics.js +12 -0
  118. package/dist/submission-diagnostics.js.map +1 -0
  119. package/dist/submission-guidance.d.ts +119 -0
  120. package/dist/submission-guidance.d.ts.map +1 -0
  121. package/dist/submission-guidance.js +766 -0
  122. package/dist/submission-guidance.js.map +1 -0
  123. package/dist/submission-receipt.d.ts +48 -0
  124. package/dist/submission-receipt.d.ts.map +1 -0
  125. package/dist/submission-receipt.js +336 -0
  126. package/dist/submission-receipt.js.map +1 -0
  127. package/dist/verification-gate.d.ts +13 -7
  128. package/dist/verification-gate.d.ts.map +1 -1
  129. package/dist/verification-gate.js +149 -90
  130. package/dist/verification-gate.js.map +1 -1
  131. package/dist/version.d.ts +1 -1
  132. package/dist/version.js +1 -1
  133. package/loop_policy.json +58 -59
  134. package/package.json +68 -67
  135. package/skills/perception/SKILL.md +90 -42
package/dist/self-eval.js CHANGED
@@ -2,17 +2,82 @@
2
2
  * The MCP boundary validates required fields before these helpers run.
3
3
  */
4
4
  import { makeExecutionReport, makeSelfEvaluation, } from "./protocol.js";
5
+ import { retiredFieldDetail, submittedFieldPaths, unknownExecutionReportEntryFields, unknownTopLevelFields, } from "./submission-guidance.js";
6
+ import { DIAG_ALL_ENTRIES_DROPPED, DIAG_EMPTY_VALUE, } from "./submission-diagnostics.js";
7
+ const DIAGNOSTICS_LIMIT = 100;
8
+ class DiagnosticsCollector {
9
+ entries = [];
10
+ omitted = 0;
11
+ push(path, code, handling, detail) {
12
+ if (this.entries.length >= DIAGNOSTICS_LIMIT) {
13
+ this.omitted++;
14
+ return;
15
+ }
16
+ this.entries.push({ path, code, handling, detail });
17
+ }
18
+ finish() {
19
+ if (this.omitted > 0) {
20
+ this.entries.push({
21
+ path: "diagnostics",
22
+ code: "diagnostics_truncated",
23
+ handling: "truncated",
24
+ detail: `${this.omitted} more diagnostic(s) omitted`,
25
+ });
26
+ }
27
+ return this.entries;
28
+ }
29
+ }
5
30
  // ── Raw parsing helpers ───────────────────────────────────────────────────
6
- function boundedString(value, maxChars) {
7
- return typeof value === "string" ? value.slice(0, maxChars) : undefined;
31
+ function boundedString(value, maxChars, sink, path = "") {
32
+ if (value === undefined)
33
+ return undefined;
34
+ if (value === null) {
35
+ sink?.push(path, "invalid_value", "dropped", "expected a string; null was dropped");
36
+ return undefined;
37
+ }
38
+ if (typeof value !== "string") {
39
+ sink?.push(path, "invalid_value", "dropped", `expected a string; the value was dropped`);
40
+ return undefined;
41
+ }
42
+ if (value.length > maxChars) {
43
+ sink?.push(path, "truncated", "truncated", `longer than ${maxChars} chars; truncated to the limit`);
44
+ return value.slice(0, maxChars);
45
+ }
46
+ return value;
8
47
  }
9
- function boundedStringArray(value, maxItems, maxChars) {
10
- return Array.isArray(value)
11
- ? value
12
- .filter((item) => typeof item === "string")
13
- .map((item) => item.slice(0, maxChars))
14
- .slice(0, maxItems)
15
- : [];
48
+ function boundedStringArray(value, maxItems, maxChars, sink, path = "") {
49
+ if (value === undefined || value === null)
50
+ return [];
51
+ if (!Array.isArray(value)) {
52
+ sink?.push(path, "not_array", "dropped", `expected an array of strings; the value was dropped`);
53
+ return [];
54
+ }
55
+ const strings = value.filter((item) => typeof item === "string");
56
+ const invalid = value.length - strings.length;
57
+ if (invalid > 0) {
58
+ if (strings.length === 0) {
59
+ sink?.push(path, DIAG_ALL_ENTRIES_DROPPED, "dropped", `all ${invalid} entr${invalid === 1 ? "y was" : "ies were"} invalid; the value was dropped`);
60
+ }
61
+ else {
62
+ sink?.push(path, "invalid_entry", "dropped", `${invalid} non-string entr${invalid === 1 ? "y" : "ies"} dropped`);
63
+ }
64
+ }
65
+ let truncated = 0;
66
+ const bounded = strings.map((item) => {
67
+ if (item.length > maxChars) {
68
+ truncated++;
69
+ return item.slice(0, maxChars);
70
+ }
71
+ return item;
72
+ });
73
+ if (truncated > 0) {
74
+ sink?.push(path, "truncated", "truncated", `${truncated} entr${truncated === 1 ? "y was" : "ies were"} longer than ${maxChars} chars`);
75
+ }
76
+ if (bounded.length > maxItems) {
77
+ sink?.push(path, "too_many", "truncated", `${bounded.length - maxItems} entr${bounded.length - maxItems === 1 ? "y" : "ies"} over the ${maxItems}-item cap were dropped`);
78
+ return bounded.slice(0, maxItems);
79
+ }
80
+ return bounded;
16
81
  }
17
82
  /** v3.8.1: the emerged-subtask intake bound — ONE constant, because the
18
83
  * sub-goal ids are derived by ORDINAL over this list. The strict submission
@@ -24,59 +89,105 @@ function boundedStringArray(value, maxItems, maxChars) {
24
89
  export const EMERGED_LIMITS = { items: 50, chars: 500 };
25
90
  /** THE emerged-list normalization. Both the boundary and the committed
26
91
  * evaluation call this, so the two sets cannot disagree. */
27
- export function boundedEmergedSubtasks(value) {
28
- return boundedStringArray(value, EMERGED_LIMITS.items, EMERGED_LIMITS.chars);
92
+ export function boundedEmergedSubtasks(value, sink) {
93
+ return boundedStringArray(value, EMERGED_LIMITS.items, EMERGED_LIMITS.chars, sink, "emerged_subtasks");
29
94
  }
30
- function nonNegativeInteger(value) {
31
- return typeof value === "number" && Number.isFinite(value)
32
- ? Math.max(0, Math.trunc(value))
33
- : 0;
95
+ function nonNegativeInteger(value, sink, path = "") {
96
+ if (typeof value !== "number" || !Number.isFinite(value)) {
97
+ if (value !== undefined && value !== null) {
98
+ sink?.push(path, "invalid_value", "normalized", "expected a finite number; defaulted to 0");
99
+ }
100
+ return 0;
101
+ }
102
+ const normalized = Math.max(0, Math.trunc(value));
103
+ if (normalized !== value) {
104
+ sink?.push(path, "normalized", "normalized", `expected a non-negative integer; normalized to ${normalized}`);
105
+ }
106
+ return normalized;
34
107
  }
35
108
  /** v2.12: Parse retroactive claims — {round ≥ 1, claim non-empty}, capped at
36
109
  * 20 entries. Invalid entries are silently dropped (lenient parsing). */
37
- function parseRetroactiveClaims(raw) {
38
- if (!Array.isArray(raw))
110
+ function parseRetroactiveClaims(raw, sink) {
111
+ if (raw === undefined || raw === null)
39
112
  return [];
113
+ if (!Array.isArray(raw)) {
114
+ sink?.push("retroactiveClaims", "not_array", "dropped", "expected an array of { round, claim }; the value was dropped");
115
+ return [];
116
+ }
40
117
  const claims = [];
118
+ let invalid = 0;
41
119
  for (const item of raw) {
42
120
  if (claims.length >= 20)
43
121
  break;
44
- if (typeof item !== "object" || item === null || Array.isArray(item))
122
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
123
+ invalid++;
45
124
  continue;
125
+ }
46
126
  const record = item;
47
127
  if (typeof record.round !== "number" || !Number.isInteger(record.round) ||
48
128
  record.round < 1 || typeof record.claim !== "string" ||
49
129
  record.claim.trim().length === 0) {
130
+ invalid++;
50
131
  continue;
51
132
  }
52
- claims.push({ round: record.round, claim: record.claim.slice(0, 500) });
133
+ const claim = record.claim.length > 500 ? record.claim.slice(0, 500) : record.claim;
134
+ if (claim !== record.claim) {
135
+ sink?.push("retroactiveClaims", "truncated", "truncated", "a claim was longer than 500 chars and was truncated");
136
+ }
137
+ claims.push({ round: record.round, claim });
138
+ }
139
+ if (invalid > 0) {
140
+ sink?.push("retroactiveClaims", claims.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", claims.length === 0
141
+ ? `all ${invalid} entries were malformed; the value was dropped`
142
+ : `${invalid} malformed entr${invalid === 1 ? "y" : "ies"} dropped`);
143
+ }
144
+ if (raw.length > 20) {
145
+ sink?.push("retroactiveClaims", "too_many", "truncated", `only the first 20 of ${raw.length} entries are kept`);
53
146
  }
54
147
  return claims;
55
148
  }
56
149
  /** Parse ExecutionReport from a raw JSON object. */
57
- export function parseExecutionReport(raw) {
58
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
150
+ export function parseExecutionReport(raw, sink) {
151
+ if (raw === undefined || raw === null)
152
+ return undefined;
153
+ if (typeof raw !== "object" || Array.isArray(raw)) {
154
+ sink?.push("execution_report", "invalid_entry", "dropped", "expected an object; the value was dropped");
59
155
  return undefined;
156
+ }
157
+ for (const key of unknownExecutionReportEntryFields(raw)) {
158
+ sink?.push(`execution_report.${key}`, "unknown_field", "dropped", "not a declared execution_report field; it was dropped");
159
+ }
60
160
  const testResults = raw.tests_reported && typeof raw.tests_reported === "object" &&
61
161
  !Array.isArray(raw.tests_reported)
62
162
  ? raw.tests_reported
63
163
  : undefined;
64
- return makeExecutionReport({
65
- files_changed: boundedStringArray(raw.files_changed, 200, 500),
66
- tests_reported: testResults && typeof testResults.passed === "number" &&
67
- Number.isFinite(testResults.passed)
68
- ? {
69
- passed: nonNegativeInteger(testResults.passed),
70
- failed: nonNegativeInteger(testResults.failed),
71
- skipped: nonNegativeInteger(testResults.skipped),
164
+ let tests = null;
165
+ if (raw.tests_reported !== undefined && raw.tests_reported !== null) {
166
+ if (!testResults) {
167
+ sink?.push("execution_report.tests_reported", "invalid_entry", "dropped", "expected { passed, failed, skipped }; the value was dropped");
168
+ }
169
+ else if (typeof testResults.passed !== "number" ||
170
+ !Number.isFinite(testResults.passed)) {
171
+ sink?.push("execution_report.tests_reported", "invalid_value", "dropped", "`passed` must be a finite number; the whole object was dropped");
172
+ }
173
+ else {
174
+ for (const key of Object.keys(testResults)) {
175
+ if (key !== "passed" && key !== "failed" && key !== "skipped") {
176
+ sink?.push(`execution_report.tests_reported.${key}`, "unknown_field", "dropped", "not a declared test-count field; it was dropped");
177
+ }
72
178
  }
73
- : null,
74
- criterion_claims: parseCriterionClaims(raw.criterion_claims),
75
- contract_item_claims: parseContractItemClaims(raw.contract_item_claims),
76
- progress_estimate: typeof raw.progress_estimate === "number" &&
77
- Number.isFinite(raw.progress_estimate)
78
- ? Math.max(0, Math.min(1, raw.progress_estimate))
79
- : 0.0,
179
+ tests = {
180
+ passed: nonNegativeInteger(testResults.passed, sink, "execution_report.tests_reported.passed"),
181
+ failed: nonNegativeInteger(testResults.failed, sink, "execution_report.tests_reported.failed"),
182
+ skipped: nonNegativeInteger(testResults.skipped, sink, "execution_report.tests_reported.skipped"),
183
+ };
184
+ }
185
+ }
186
+ return makeExecutionReport({
187
+ files_changed: boundedStringArray(raw.files_changed, 200, 500, sink, "execution_report.files_changed"),
188
+ tests_reported: tests,
189
+ criterion_claims: parseCriterionClaims(raw.criterion_claims, sink),
190
+ contract_item_claims: parseContractItemClaims(raw.contract_item_claims, sink),
80
191
  });
81
192
  }
82
193
  /** v3.8: The criterion ids the agent claims met this round. */
@@ -94,63 +205,131 @@ export function claimedRemainingCriteria(report) {
94
205
  /** v3.8: LENIENT parse of advisory criterion claims — unknown or malformed
95
206
  * entries are dropped, never a rejection (the contract item layer is the
96
207
  * strict one). */
97
- function parseCriterionClaims(raw) {
98
- if (!Array.isArray(raw))
208
+ function parseCriterionClaims(raw, sink) {
209
+ if (raw === undefined || raw === null)
210
+ return [];
211
+ if (!Array.isArray(raw)) {
212
+ sink?.push("execution_report.criterion_claims", "not_array", "dropped", "expected an array of claims; the value was dropped");
99
213
  return [];
214
+ }
100
215
  const claims = [];
101
- for (const item of raw) {
216
+ let invalid = 0;
217
+ for (let index = 0; index < raw.length; index++) {
102
218
  if (claims.length >= 100)
103
219
  break;
104
- if (typeof item !== "object" || item === null || Array.isArray(item))
220
+ const item = raw[index];
221
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
222
+ invalid++;
105
223
  continue;
224
+ }
106
225
  const entry = item;
107
- if (typeof entry.criterion_id !== "string" || entry.criterion_id.trim().length === 0)
108
- continue;
109
- if (entry.outcome !== "met" && entry.outcome !== "remaining")
226
+ if (typeof entry.criterion_id !== "string" ||
227
+ entry.criterion_id.trim().length === 0 ||
228
+ (entry.outcome !== "met" && entry.outcome !== "remaining")) {
229
+ invalid++;
110
230
  continue;
231
+ }
232
+ const trimmed = entry.criterion_id.trim();
233
+ if (trimmed !== entry.criterion_id) {
234
+ sink?.push(`execution_report.criterion_claims[${index}].criterion_id`, "normalized", "normalized", "leading/trailing whitespace was trimmed");
235
+ }
236
+ if (trimmed.length > 500) {
237
+ sink?.push(`execution_report.criterion_claims[${index}].criterion_id`, "truncated", "truncated", "longer than 500 chars; truncated to the limit");
238
+ }
111
239
  claims.push({
112
- criterion_id: entry.criterion_id.trim().slice(0, 500),
240
+ criterion_id: trimmed.slice(0, 500),
113
241
  outcome: entry.outcome,
114
242
  });
115
243
  }
244
+ if (invalid > 0) {
245
+ sink?.push("execution_report.criterion_claims", claims.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", claims.length === 0
246
+ ? `all ${invalid} claims were malformed; the value was dropped (advisory)`
247
+ : `${invalid} malformed claim${invalid === 1 ? "" : "s"} dropped (advisory)`);
248
+ }
249
+ if (raw.length > 100) {
250
+ sink?.push("execution_report.criterion_claims", "too_many", "truncated", "only the first 100 claims are kept");
251
+ }
116
252
  return claims;
117
253
  }
118
254
  /** v3.8: LENIENT parse of contract item claims. Referential validity is the
119
255
  * strict pre-advance boundary in validateContractShape — this parser only
120
256
  * normalizes what reaches the read model. */
121
- function parseContractItemClaims(raw) {
122
- if (!Array.isArray(raw))
257
+ function parseContractItemClaims(raw, sink) {
258
+ if (raw === undefined || raw === null)
259
+ return [];
260
+ if (!Array.isArray(raw)) {
261
+ sink?.push("execution_report.contract_item_claims", "not_array", "dropped", "expected an array of claims; the value was dropped");
123
262
  return [];
263
+ }
124
264
  const claims = [];
125
- for (const item of raw) {
265
+ let invalid = 0;
266
+ for (let index = 0; index < raw.length; index++) {
126
267
  if (claims.length >= 20)
127
268
  break;
128
- if (typeof item !== "object" || item === null || Array.isArray(item))
269
+ const item = raw[index];
270
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
271
+ invalid++;
129
272
  continue;
273
+ }
130
274
  const entry = item;
131
- if (typeof entry.item_id !== "string")
132
- continue;
133
- if (entry.outcome !== "met" && entry.outcome !== "remaining")
275
+ if (typeof entry.item_id !== "string" ||
276
+ (entry.outcome !== "met" && entry.outcome !== "remaining")) {
277
+ invalid++;
134
278
  continue;
279
+ }
280
+ const trimmed = entry.item_id.trim();
281
+ if (trimmed !== entry.item_id) {
282
+ sink?.push(`execution_report.contract_item_claims[${index}].item_id`, "normalized", "normalized", "leading/trailing whitespace was trimmed");
283
+ }
284
+ if (trimmed.length > 64) {
285
+ sink?.push(`execution_report.contract_item_claims[${index}].item_id`, "truncated", "truncated", "longer than 64 chars; truncated to the limit");
286
+ }
135
287
  claims.push({
136
- item_id: entry.item_id.trim().slice(0, 64),
288
+ item_id: trimmed.slice(0, 64),
137
289
  outcome: entry.outcome,
138
290
  });
139
291
  }
292
+ if (invalid > 0) {
293
+ sink?.push("execution_report.contract_item_claims", claims.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", claims.length === 0
294
+ ? `all ${invalid} claims were malformed; the value was dropped`
295
+ : `${invalid} malformed claim${invalid === 1 ? "" : "s"} dropped`);
296
+ }
297
+ if (raw.length > 20) {
298
+ sink?.push("execution_report.contract_item_claims", "too_many", "truncated", "only the first 20 claims are kept");
299
+ }
140
300
  return claims;
141
301
  }
142
302
  /** Parse CriterionRevision[] from a raw JSON array. */
143
- export function parseCriterionRevisions(raw) {
144
- if (!Array.isArray(raw))
303
+ export function parseCriterionRevisions(raw, sink) {
304
+ if (raw === undefined || raw === null)
145
305
  return [];
146
- return raw
147
- .filter((v) => typeof v === "object" && v !== null &&
306
+ if (!Array.isArray(raw)) {
307
+ sink?.push("revised_success_criteria", "not_array", "dropped", "expected an array of { old, new }; the value was dropped");
308
+ return [];
309
+ }
310
+ const valid = raw.filter((v) => typeof v === "object" && v !== null &&
148
311
  typeof v.old === "string" &&
149
- typeof v.new === "string")
150
- .slice(0, 20)
151
- .map((v) => {
312
+ typeof v.new === "string");
313
+ const invalid = raw.length - valid.length;
314
+ if (invalid > 0) {
315
+ sink?.push("revised_success_criteria", valid.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", valid.length === 0
316
+ ? `all ${invalid} entries lacked string old/new; the value was dropped`
317
+ : `${invalid} entr${invalid === 1 ? "y" : "ies"} without string old/new dropped`);
318
+ }
319
+ if (valid.length > 20) {
320
+ sink?.push("revised_success_criteria", "too_many", "truncated", "only the first 20 revisions are kept");
321
+ }
322
+ return valid.slice(0, 20).map((v, index) => {
152
323
  const r = v;
153
- return { old: r.old.slice(0, 500), new: r.new.slice(0, 500) };
324
+ for (const key of ["old", "new"]) {
325
+ if (r[key].length > 500) {
326
+ sink?.push(`revised_success_criteria[${index}].${key}`, "truncated", "truncated", "longer than 500 chars; truncated to the limit");
327
+ }
328
+ }
329
+ return {
330
+ old: r.old.slice(0, 500),
331
+ new: r.new.slice(0, 500),
332
+ };
154
333
  });
155
334
  }
156
335
  /** Parse SubGoalUpdate[] from a raw JSON array.
@@ -158,24 +337,55 @@ export function parseCriterionRevisions(raw) {
158
337
  * (20 entries, id ≤ 64, note ≤ 300). Referential validity — unknown IDs
159
338
  * and illegal migrations — is NOT checked here: that needs the committed
160
339
  * sub-goal set and runs as a pre-advance validation (evaluation_invalid). */
161
- export function parseSubGoalUpdates(raw) {
162
- if (!Array.isArray(raw))
340
+ export function parseSubGoalUpdates(raw, sink) {
341
+ if (raw === undefined || raw === null)
342
+ return [];
343
+ if (!Array.isArray(raw)) {
344
+ sink?.push("subgoal_updates", "not_array", "dropped", "expected an array of { id, status }; the value was dropped");
163
345
  return [];
164
- return raw
165
- .filter((v) => typeof v === "object" && v !== null &&
346
+ }
347
+ const valid = raw.filter((v) => typeof v === "object" && v !== null &&
166
348
  typeof v.id === "string" &&
167
349
  (v.status === "in_progress" ||
168
350
  v.status === "done" ||
169
351
  v.status === "blocked" ||
170
- v.status === "canceled"))
171
- .slice(0, 20)
172
- .map((u) => {
352
+ v.status === "canceled"));
353
+ const invalid = raw.length - valid.length;
354
+ if (invalid > 0) {
355
+ sink?.push("subgoal_updates", valid.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", valid.length === 0
356
+ ? `all ${invalid} transitions were malformed; the value was dropped`
357
+ : `${invalid} malformed transition${invalid === 1 ? "" : "s"} dropped`);
358
+ }
359
+ if (valid.length > 20) {
360
+ sink?.push("subgoal_updates", "too_many", "truncated", "only the first 20 transitions are kept");
361
+ }
362
+ return valid.slice(0, 20).map((u, index) => {
363
+ const rawId = u.id;
364
+ const trimmedId = rawId.trim();
365
+ if (trimmedId !== rawId) {
366
+ sink?.push(`subgoal_updates[${index}].id`, "normalized", "normalized", "leading/trailing whitespace was trimmed");
367
+ }
368
+ if (trimmedId.length > 64) {
369
+ sink?.push(`subgoal_updates[${index}].id`, "truncated", "truncated", "longer than 64 chars; truncated to the limit");
370
+ }
173
371
  const update = {
174
- id: u.id.trim().slice(0, 64),
372
+ id: trimmedId.slice(0, 64),
175
373
  status: u.status,
176
374
  };
177
- if (typeof u.note === "string" && u.note.trim().length > 0) {
178
- update.note = u.note.trim().slice(0, 300);
375
+ if (u.note !== undefined) {
376
+ if (typeof u.note !== "string") {
377
+ sink?.push(`subgoal_updates[${index}].note`, "invalid_value", "dropped", "expected a string; the note was dropped");
378
+ }
379
+ else if (u.note.trim().length === 0) {
380
+ sink?.push(`subgoal_updates[${index}].note`, DIAG_EMPTY_VALUE, "dropped", "empty note was dropped");
381
+ }
382
+ else {
383
+ const note = u.note.trim();
384
+ if (note.length > 300) {
385
+ sink?.push(`subgoal_updates[${index}].note`, "truncated", "truncated", "a note was longer than 300 chars and was truncated");
386
+ }
387
+ update.note = note.slice(0, 300);
388
+ }
179
389
  }
180
390
  return update;
181
391
  });
@@ -185,31 +395,59 @@ export function parseSubGoalUpdates(raw) {
185
395
  * is dropped (not derived, not defaulted) — the array stays informational:
186
396
  * absent/empty arrays are accepted unchanged, so a round without any
187
397
  * delegation can never be rejected over worker fields. */
188
- export function parseWorkerResults(raw) {
189
- if (!Array.isArray(raw))
398
+ export function parseWorkerResults(raw, sink) {
399
+ if (raw === undefined || raw === null)
190
400
  return [];
191
- return raw
192
- .filter((v) => {
193
- if (typeof v !== "object" || v === null)
401
+ if (!Array.isArray(raw)) {
402
+ sink?.push("worker_results", "not_array", "dropped", "expected an array of worker reports; the value was dropped");
403
+ return [];
404
+ }
405
+ const valid = raw.filter((v) => {
406
+ if (typeof v !== "object" || v === null || Array.isArray(v))
194
407
  return false;
195
408
  const w = v;
196
409
  return typeof w.agentId === "string" &&
197
410
  typeof w.subTask === "string" &&
198
411
  typeof w.resultSummary === "string" &&
199
412
  (w.outcome === "success" || w.outcome === "partial" || w.outcome === "failed");
200
- })
201
- .slice(0, 20)
202
- .map((v) => {
413
+ });
414
+ const invalid = raw.length - valid.length;
415
+ if (invalid > 0) {
416
+ sink?.push("worker_results", valid.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", valid.length === 0
417
+ ? `all ${invalid} reports were malformed; the value was dropped`
418
+ : `${invalid} report${invalid === 1 ? "" : "s"} missing a string agentId/subTask/resultSummary or a valid outcome dropped`);
419
+ }
420
+ if (valid.length > 20) {
421
+ sink?.push("worker_results", "too_many", "truncated", "only the first 20 worker reports are kept");
422
+ }
423
+ return valid.slice(0, 20).map((v, index) => {
203
424
  const w = v;
425
+ for (const key of Object.keys(w)) {
426
+ if (!["agentId", "subAgentType", "subTask", "resultSummary", "outcome",
427
+ "discoveredConstraints"].includes(key)) {
428
+ sink?.push(`worker_results[${index}].${key}`, "unknown_field", "dropped", "not a declared worker-result field; it was dropped");
429
+ }
430
+ }
431
+ if (w.subAgentType !== undefined && typeof w.subAgentType !== "string") {
432
+ sink?.push(`worker_results[${index}].subAgentType`, "invalid_value", "normalized", "expected a string; defaulted to general-purpose");
433
+ }
434
+ for (const [key, cap] of [
435
+ ["agentId", 200], ["subAgentType", 100], ["subTask", 500], ["resultSummary", 1000],
436
+ ]) {
437
+ const value = w[key];
438
+ if (typeof value === "string" && value.length > cap) {
439
+ sink?.push(`worker_results[${index}].${key}`, "truncated", "truncated", `longer than ${cap} chars; truncated to the limit`);
440
+ }
441
+ }
204
442
  return {
205
- agentId: w.agentId.slice(0, 200),
443
+ agentId: (w.agentId.slice(0, 200)),
206
444
  subAgentType: typeof w.subAgentType === "string"
207
445
  ? w.subAgentType.slice(0, 100)
208
446
  : "general-purpose",
209
- subTask: w.subTask.slice(0, 500),
210
- resultSummary: w.resultSummary.slice(0, 1000),
447
+ subTask: (w.subTask.slice(0, 500)),
448
+ resultSummary: (w.resultSummary.slice(0, 1000)),
211
449
  outcome: w.outcome,
212
- discoveredConstraints: boundedStringArray(w.discoveredConstraints, 50, 500),
450
+ discoveredConstraints: boundedStringArray(w.discoveredConstraints, 50, 500, sink, `worker_results[${index}].discoveredConstraints`),
213
451
  };
214
452
  });
215
453
  }
@@ -624,31 +862,86 @@ export function validateContractShape(raw, context) {
624
862
  return errors;
625
863
  }
626
864
  /** Build a SelfEvaluation from a parsed JSON object.
627
- * Lenient parsing: missing optional fields get sensible defaults. */
628
- export function buildSelfEvaluation(raw) {
629
- const executionReport = parseExecutionReport(raw.execution_report);
630
- const retractedConstraints = boundedStringArray(raw.retracted_constraints, 50, 500);
631
- const revisedCriteria = parseCriterionRevisions(raw.revised_success_criteria);
632
- const wrongAssumptions = boundedStringArray(raw.wrong_assumptions, 50, 500);
633
- const workerResults = parseWorkerResults(raw.worker_results);
865
+ * Lenient parsing: missing optional fields get sensible defaults. When a
866
+ * diagnostic sink is supplied, every drop/truncation/normalization is
867
+ * reported; without one (the engine's historical payload parse) the values
868
+ * are normalized as before. */
869
+ export function buildSelfEvaluation(raw, sink) {
870
+ const executionReport = parseExecutionReport(raw.execution_report, sink);
871
+ const retractedConstraints = boundedStringArray(raw.retracted_constraints, 50, 500, sink, "retracted_constraints");
872
+ const revisedCriteria = parseCriterionRevisions(raw.revised_success_criteria, sink);
873
+ const wrongAssumptions = boundedStringArray(raw.wrong_assumptions, 50, 500, sink, "wrong_assumptions");
874
+ const workerResults = parseWorkerResults(raw.worker_results, sink);
875
+ if (raw.success !== undefined && typeof raw.success !== "boolean") {
876
+ sink?.push("success", "invalid_value", "dropped", "expected a boolean; normalized to false (core validation rejects it)");
877
+ }
878
+ if (raw.output_summary !== undefined && typeof raw.output_summary !== "string") {
879
+ sink?.push("output_summary", "invalid_value", "dropped", "expected a string; normalized to the empty string (core validation rejects it)");
880
+ }
881
+ let constraintViolations = [];
882
+ if (raw.constraint_violations !== undefined) {
883
+ if (!Array.isArray(raw.constraint_violations)) {
884
+ sink?.push("constraint_violations", "not_array", "dropped", "expected an array of strings; normalized to [] (core validation rejects it)");
885
+ }
886
+ else {
887
+ constraintViolations = raw.constraint_violations
888
+ .filter((v) => typeof v === "string");
889
+ const invalid = raw.constraint_violations.length - constraintViolations.length;
890
+ if (invalid > 0) {
891
+ sink?.push("constraint_violations", constraintViolations.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", constraintViolations.length === 0
892
+ ? `all ${invalid} entries were non-strings; the value was dropped`
893
+ : `${invalid} non-string entr${invalid === 1 ? "y" : "ies"} dropped`);
894
+ }
895
+ }
896
+ }
897
+ if (raw.should_continue !== undefined && typeof raw.should_continue !== "boolean") {
898
+ sink?.push("should_continue", "invalid_value", "dropped", "expected a boolean; normalized to true (core validation rejects it)");
899
+ }
900
+ if (raw.stop_reason !== undefined &&
901
+ raw.stop_reason !== "gave_up" && raw.stop_reason !== "blocked" &&
902
+ raw.stop_reason !== "needs_human_input") {
903
+ sink?.push("stop_reason", "invalid_enum", "dropped", "expected gave_up | blocked | needs_human_input; the value was dropped");
904
+ }
905
+ if (raw.outcome !== undefined &&
906
+ raw.outcome !== "success" && raw.outcome !== "partial" &&
907
+ raw.outcome !== "failed" && raw.outcome !== "blocked") {
908
+ sink?.push("outcome", "invalid_enum", "dropped", "expected success | partial | failed | blocked; the value was dropped");
909
+ }
910
+ if (raw.compression_checkpoint !== undefined &&
911
+ typeof raw.compression_checkpoint !== "boolean") {
912
+ sink?.push("compression_checkpoint", "invalid_value", "dropped", "expected a boolean; normalized to false");
913
+ }
914
+ if (raw.blocker !== undefined) {
915
+ if (typeof raw.blocker !== "string") {
916
+ sink?.push("blocker", "invalid_value", "dropped", "expected a string; the value was dropped");
917
+ }
918
+ else if (raw.blocker.trim().length === 0) {
919
+ sink?.push("blocker", DIAG_EMPTY_VALUE, "dropped", "empty blocker was dropped");
920
+ }
921
+ else if (raw.blocker.length > 500) {
922
+ sink?.push("blocker", "truncated", "truncated", "longer than 500 chars; truncated to the limit");
923
+ }
924
+ }
925
+ const noChangeReason = boundedString(raw.no_change_reason, 200, sink, "no_change_reason");
926
+ if (raw.no_change_reason === "") {
927
+ sink?.push("no_change_reason", DIAG_EMPTY_VALUE, "dropped", "empty reason was dropped");
928
+ }
634
929
  return makeSelfEvaluation({
635
930
  success: typeof raw.success === "boolean" ? raw.success : false,
636
931
  output_summary: typeof raw.output_summary === "string" ? raw.output_summary : "",
637
- constraint_violations: Array.isArray(raw.constraint_violations)
638
- ? raw.constraint_violations.filter((v) => typeof v === "string")
639
- : [],
932
+ constraint_violations: constraintViolations,
640
933
  should_continue: typeof raw.should_continue === "boolean" ? raw.should_continue : true,
641
- discovered_constraints: boundedStringArray(raw.discovered_constraints, 50, 500),
642
- objective_refinement: boundedString(raw.objective_refinement, 1000) ?? "",
643
- emerged_subtasks: boundedEmergedSubtasks(raw.emerged_subtasks),
934
+ discovered_constraints: boundedStringArray(raw.discovered_constraints, 50, 500, sink, "discovered_constraints"),
935
+ objective_refinement: boundedString(raw.objective_refinement, 1000, sink, "objective_refinement") ?? "",
936
+ emerged_subtasks: boundedEmergedSubtasks(raw.emerged_subtasks, sink),
644
937
  execution_report: executionReport,
645
938
  retracted_constraints: retractedConstraints,
646
939
  revised_success_criteria: revisedCriteria,
647
940
  wrong_assumptions: wrongAssumptions,
648
941
  worker_results: workerResults,
649
942
  compression_checkpoint: typeof raw.compression_checkpoint === "boolean" ? raw.compression_checkpoint : false,
650
- checkpoint_label: boundedString(raw.checkpoint_label, 200) ?? "",
651
- subgoal_updates: parseSubGoalUpdates(raw.subgoal_updates),
943
+ checkpoint_label: boundedString(raw.checkpoint_label, 200, sink, "checkpoint_label") ?? "",
944
+ subgoal_updates: parseSubGoalUpdates(raw.subgoal_updates, sink),
652
945
  stop_reason: raw.stop_reason === "gave_up" || raw.stop_reason === "blocked" || raw.stop_reason === "needs_human_input"
653
946
  ? raw.stop_reason
654
947
  : undefined,
@@ -659,15 +952,27 @@ export function buildSelfEvaluation(raw) {
659
952
  blocker: typeof raw.blocker === "string" && raw.blocker.trim().length > 0
660
953
  ? raw.blocker.slice(0, 500)
661
954
  : undefined,
662
- gate_ids: boundedStringArray(raw.gate_ids, 20, 64),
663
- retroactiveClaims: parseRetroactiveClaims(raw.retroactiveClaims),
664
- no_change_reason: typeof raw.no_change_reason === "string" && raw.no_change_reason.trim().length > 0
665
- ? raw.no_change_reason.slice(0, 200)
666
- : undefined,
667
- prompt_requests: parsePromptRequests(raw.prompt_requests),
668
- round_contract: parseRoundContract(raw.round_contract),
955
+ gate_ids: boundedStringArray(raw.gate_ids, 20, 64, sink, "gate_ids"),
956
+ retroactiveClaims: parseRetroactiveClaims(raw.retroactiveClaims, sink),
957
+ no_change_reason: noChangeReason || undefined,
958
+ prompt_requests: parsePromptRequests(raw.prompt_requests, sink),
959
+ round_contract: parseRoundContract(raw.round_contract, sink),
669
960
  });
670
961
  }
962
+ /** Normalize a raw submission and report every lossy decision. Unknown and
963
+ * retired fields are diagnosed and dropped; they are never silently accepted. */
964
+ export function normalizeSelfEvaluation(raw) {
965
+ const collector = new DiagnosticsCollector();
966
+ for (const key of unknownTopLevelFields(raw)) {
967
+ collector.push(key, "unknown_field", "dropped", retiredFieldDetail(key) ?? "not a declared evaluation field; it was dropped");
968
+ }
969
+ const evaluation = buildSelfEvaluation(raw, collector);
970
+ return {
971
+ evaluation,
972
+ diagnostics: collector.finish(),
973
+ submittedFields: submittedFieldPaths(raw),
974
+ };
975
+ }
671
976
  /** v3.8: The ONE set of contract-declaration limits. `validateContractShape`
672
977
  * (the strict boundary) rejects a declaration over these; `parseRoundContract`
673
978
  * (the lenient path, reached only after the strict boundary passed) truncates
@@ -684,35 +989,110 @@ export const CONTRACT_LIMITS = {
684
989
  * capped, non-string array entries are dropped. Item ORDER and COUNT are
685
990
  * preserved — the derived rci- ids depend on them. Returns undefined only
686
991
  * when the raw value is absent or not an object. */
687
- export function parseRoundContract(raw) {
688
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
992
+ export function parseRoundContract(raw, sink) {
993
+ if (raw === undefined)
994
+ return undefined;
995
+ if (raw === null) {
996
+ sink?.push("round_contract", "invalid_value", "dropped", "expected a contract object; null was dropped");
689
997
  return undefined;
998
+ }
999
+ if (typeof raw !== "object" || Array.isArray(raw)) {
1000
+ sink?.push("round_contract", "invalid_entry", "dropped", "expected a contract object; the value was dropped");
1001
+ return undefined;
1002
+ }
690
1003
  const obj = raw;
691
- const capStrings = (value, max, cap) => Array.isArray(value)
692
- ? value
693
- .filter((v) => typeof v === "string")
694
- .map((v) => v.trim().slice(0, max))
695
- .filter(Boolean)
696
- .slice(0, cap)
697
- : [];
698
- const items = Array.isArray(obj.items)
699
- ? obj.items
700
- .slice(0, CONTRACT_LIMITS.items)
701
- .filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
702
- .map((item) => ({
1004
+ for (const key of Object.keys(obj)) {
1005
+ if (!["work_item", "scope", "items"].includes(key)) {
1006
+ sink?.push(`round_contract.${key}`, "unknown_field", "dropped", "not a declared contract field; it was dropped");
1007
+ }
1008
+ }
1009
+ const capStrings = (value, max, cap, path) => {
1010
+ if (value === undefined)
1011
+ return [];
1012
+ if (!Array.isArray(value)) {
1013
+ sink?.push(path, "invalid_entry", "dropped", "expected an array of strings; the value was dropped");
1014
+ return [];
1015
+ }
1016
+ const strings = value.filter((v) => typeof v === "string");
1017
+ const invalid = value.length - strings.length;
1018
+ if (invalid > 0) {
1019
+ sink?.push(`${path}[]`, strings.length === 0 ? DIAG_ALL_ENTRIES_DROPPED : "invalid_entry", "dropped", strings.length === 0
1020
+ ? `all ${invalid} entries were invalid; the value was dropped`
1021
+ : `${invalid} non-string entr${invalid === 1 ? "y" : "ies"} dropped`);
1022
+ }
1023
+ const trimmed = strings.map((v) => v.trim()).filter(Boolean);
1024
+ const empty = strings.length - trimmed.length;
1025
+ if (empty > 0) {
1026
+ sink?.push(`${path}[]`, DIAG_EMPTY_VALUE, "dropped", `${empty} empty entr${empty === 1 ? "y was" : "ies were"} dropped`);
1027
+ }
1028
+ const bounded = trimmed.map((v) => v.slice(0, max));
1029
+ if (bounded.some((v, index) => v !== trimmed[index])) {
1030
+ sink?.push(`${path}[]`, "truncated", "truncated", `a contract string was longer than ${max} chars`);
1031
+ }
1032
+ if (bounded.length > cap) {
1033
+ sink?.push(path, "too_many", "truncated", `only the first ${cap} entries are kept`);
1034
+ }
1035
+ return bounded.slice(0, cap);
1036
+ };
1037
+ const rawItems = obj.items;
1038
+ if (rawItems === undefined) {
1039
+ sink?.push("round_contract", "invalid_value", "dropped", "expected an items array; the proposal was dropped");
1040
+ return undefined;
1041
+ }
1042
+ if (!Array.isArray(rawItems)) {
1043
+ sink?.push("round_contract", "invalid_value", "dropped", "items must be an array; the proposal was dropped");
1044
+ return undefined;
1045
+ }
1046
+ const objectItems = rawItems.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item));
1047
+ const nonObject = rawItems.length - objectItems.length;
1048
+ if (nonObject > 0) {
1049
+ sink?.push("round_contract.items", "invalid_entry", "dropped", `${nonObject} non-object item${nonObject === 1 ? "" : "s"} dropped`);
1050
+ }
1051
+ const items = objectItems
1052
+ .slice(0, CONTRACT_LIMITS.items)
1053
+ .map((item, index) => {
1054
+ for (const key of Object.keys(item)) {
1055
+ if (!["description", "criterion_refs", "subgoal_refs", "verify_with"].includes(key)) {
1056
+ sink?.push(`round_contract.items[${index}].${key}`, "unknown_field", "dropped", "not a declared contract-item field; it was dropped");
1057
+ }
1058
+ }
1059
+ if (item.description !== undefined && typeof item.description !== "string") {
1060
+ sink?.push(`round_contract.items[${index}].description`, "invalid_value", "dropped", "expected a string; the description was dropped");
1061
+ }
1062
+ if (typeof item.description === "string" &&
1063
+ item.description.trim().length > 200) {
1064
+ sink?.push(`round_contract.items[${index}].description`, "truncated", "truncated", "longer than 200 chars; truncated to the limit");
1065
+ }
1066
+ return {
703
1067
  description: typeof item.description === "string"
704
1068
  ? item.description.trim().slice(0, 200)
705
1069
  : "",
706
- criterion_refs: capStrings(item.criterion_refs, 200, CONTRACT_LIMITS.criterionRefs),
707
- subgoal_refs: capStrings(item.subgoal_refs, 64, CONTRACT_LIMITS.subgoalRefs),
708
- verify_with: capStrings(item.verify_with, 200, CONTRACT_LIMITS.verifyWith),
709
- }))
710
- : [];
1070
+ criterion_refs: capStrings(item.criterion_refs, 200, CONTRACT_LIMITS.criterionRefs, `round_contract.items[${index}].criterion_refs`),
1071
+ subgoal_refs: capStrings(item.subgoal_refs, 64, CONTRACT_LIMITS.subgoalRefs, `round_contract.items[${index}].subgoal_refs`),
1072
+ verify_with: capStrings(item.verify_with, 200, CONTRACT_LIMITS.verifyWith, `round_contract.items[${index}].verify_with`),
1073
+ };
1074
+ });
1075
+ if (rawItems.length > CONTRACT_LIMITS.items) {
1076
+ sink?.push("round_contract.items", "too_many", "truncated", `only the first ${CONTRACT_LIMITS.items} items are kept`);
1077
+ }
1078
+ if (items.length === 0) {
1079
+ sink?.push("round_contract", DIAG_ALL_ENTRIES_DROPPED, "dropped", "no valid items; the proposal was dropped");
1080
+ return undefined;
1081
+ }
1082
+ if (obj.work_item !== undefined && typeof obj.work_item !== "string") {
1083
+ sink?.push("round_contract.work_item", "invalid_value", "dropped", "expected a string; the work_item was dropped");
1084
+ }
1085
+ else if (typeof obj.work_item === "string" && obj.work_item.trim().length === 0) {
1086
+ sink?.push("round_contract.work_item", DIAG_EMPTY_VALUE, "dropped", "empty work_item was dropped");
1087
+ }
1088
+ else if (typeof obj.work_item === "string" && obj.work_item.trim().length > 200) {
1089
+ sink?.push("round_contract.work_item", "truncated", "truncated", "longer than 200 chars; truncated to the limit");
1090
+ }
711
1091
  return {
712
1092
  work_item: typeof obj.work_item === "string" && obj.work_item.trim().length > 0
713
1093
  ? obj.work_item.trim().slice(0, 200)
714
1094
  : undefined,
715
- scope: capStrings(obj.scope, 500, CONTRACT_LIMITS.scope),
1095
+ scope: capStrings(obj.scope, 500, CONTRACT_LIMITS.scope, "round_contract.scope"),
716
1096
  items,
717
1097
  };
718
1098
  }
@@ -724,15 +1104,29 @@ export function parseRoundContract(raw) {
724
1104
  * lifecycle assembles prompt_requests into the request, but every
725
1105
  * compile path funnels through engine.invokeLoopCompile, whose
726
1106
  * field-by-field reconstruction must preserve them (v3.3.1). */
727
- export function parsePromptRequests(raw) {
728
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
1107
+ export function parsePromptRequests(raw, sink) {
1108
+ if (raw === undefined)
729
1109
  return undefined;
1110
+ if (raw === null) {
1111
+ sink?.push("prompt_requests", "invalid_value", "dropped", "expected an object with emphasize/confusion_points; null was dropped");
1112
+ return undefined;
1113
+ }
1114
+ if (typeof raw !== "object" || Array.isArray(raw)) {
1115
+ sink?.push("prompt_requests", "invalid_entry", "dropped", "expected an object with emphasize/confusion_points; the value was dropped");
1116
+ return undefined;
1117
+ }
730
1118
  const obj = raw;
731
- const emphasize = boundedStringArray(obj.emphasize, 5, 500);
732
- const confusionPoints = boundedStringArray(obj.confusion_points, 3, 200);
1119
+ for (const key of Object.keys(obj)) {
1120
+ if (key !== "emphasize" && key !== "confusion_points") {
1121
+ sink?.push(`prompt_requests.${key}`, "unknown_field", "dropped", "not a declared prompt-request field; it was dropped");
1122
+ }
1123
+ }
1124
+ const emphasize = boundedStringArray(obj.emphasize, 5, 500, sink, "prompt_requests.emphasize");
1125
+ const confusionPoints = boundedStringArray(obj.confusion_points, 3, 200, sink, "prompt_requests.confusion_points");
733
1126
  // Return undefined when all fields are empty — semantically equivalent
734
1127
  // to "not set", avoids carrying an empty object through the pipeline.
735
1128
  if (emphasize.length === 0 && confusionPoints.length === 0) {
1129
+ sink?.push("prompt_requests", DIAG_EMPTY_VALUE, "dropped", "emphasize and confusion_points are both empty; treated as not set");
736
1130
  return undefined;
737
1131
  }
738
1132
  return { emphasize, confusion_points: confusionPoints };