loopforge 3.9.0 → 3.9.2

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