loopforge 3.9.2 → 4.0.7

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 (285) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +28 -887
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
package/dist/self-eval.js CHANGED
@@ -4,7 +4,8 @@
4
4
  import { makeExecutionReport, makeSelfEvaluation, } from "./protocol.js";
5
5
  import { retiredFieldDetail, submittedFieldPaths, unknownExecutionReportEntryFields, unknownTopLevelFields, } from "./submission-guidance.js";
6
6
  import { DIAG_ALL_ENTRIES_DROPPED, DIAG_EMPTY_VALUE, } from "./submission-diagnostics.js";
7
- import { criterionRefMatches } from "./token-utils.js";
7
+ import { canonicalContractItemText, criterionRefMatches, isCriterionId, } from "./token-utils.js";
8
+ import { isOpenQuestionId } from "./open-question.js";
8
9
  const DIAGNOSTICS_LIMIT = 100;
9
10
  class DiagnosticsCollector {
10
11
  entries = [];
@@ -93,19 +94,6 @@ export const EMERGED_LIMITS = { items: 50, chars: 500 };
93
94
  export function boundedEmergedSubtasks(value, sink) {
94
95
  return boundedStringArray(value, EMERGED_LIMITS.items, EMERGED_LIMITS.chars, sink, "emerged_subtasks");
95
96
  }
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;
108
- }
109
97
  /** v2.12: Parse retroactive claims — {round ≥ 1, claim non-empty}, capped at
110
98
  * 20 entries. Invalid entries are silently dropped (lenient parsing). */
111
99
  function parseRetroactiveClaims(raw, sink) {
@@ -156,61 +144,44 @@ export function parseExecutionReport(raw, sink, knownCriterionRefs) {
156
144
  return undefined;
157
145
  }
158
146
  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
- }
161
- const testResults = raw.tests_reported && typeof raw.tests_reported === "object" &&
162
- !Array.isArray(raw.tests_reported)
163
- ? raw.tests_reported
164
- : undefined;
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
- }
179
- }
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
- }
147
+ const path = `execution_report.${key}`;
148
+ sink?.push(path, "unknown_field", "dropped", retiredFieldDetail(path) ?? "not a declared execution_report field; it was dropped");
186
149
  }
187
150
  return makeExecutionReport({
188
151
  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),
152
+ claims: parseExecutionClaims(raw.claims, sink, knownCriterionRefs),
192
153
  });
193
154
  }
194
- /** v3.8: The criterion ids the agent claims met this round. */
155
+ const CLAIM_ID_RE = /^(cr|rci)-[a-f0-9]{8}$/;
156
+ function isCriterionClaimId(id) {
157
+ return id.startsWith("cr-");
158
+ }
159
+ /** v4.0.6: the claimed `cr-` criterion ids with the given outcome. */
160
+ function claimedCriteria(report, outcome) {
161
+ return (report?.claims ?? [])
162
+ .filter((claim) => claim.outcome === outcome && isCriterionClaimId(claim.id))
163
+ .map((claim) => claim.id);
164
+ }
165
+ /** v4.0.6: The criterion ids the agent claims met this round. */
195
166
  export function claimedMetCriteria(report) {
196
- return (report?.criterion_claims ?? [])
197
- .filter((claim) => claim.outcome === "met")
198
- .map((claim) => claim.criterion_id);
167
+ return claimedCriteria(report, "met");
199
168
  }
200
- /** v3.8: The criterion ids the agent declares still outstanding. */
169
+ /** v4.0.6: The criterion ids the agent declares still outstanding. */
201
170
  export function claimedRemainingCriteria(report) {
202
- return (report?.criterion_claims ?? [])
203
- .filter((claim) => claim.outcome === "remaining")
204
- .map((claim) => claim.criterion_id);
171
+ return claimedCriteria(report, "remaining");
205
172
  }
206
- /** v3.8: LENIENT parse of advisory criterion claims — unknown or malformed
207
- * entries are dropped, never a rejection (the contract item layer is the
208
- * strict one). */
209
- function parseCriterionClaims(raw, sink, knownCriterionRefs) {
173
+ /** v4.0.6: LENIENT parse of the ONE claims channel.
174
+ *
175
+ * `cr-` claims are advisory: unknown/malformed entries are dropped with a
176
+ * diagnostic, never a rejection. `rci-` claims are STRICT at the submission
177
+ * boundary (`validateContractShape` checks format, uniqueness and the ACTIVE
178
+ * item set); this parser only normalizes what reaches the read model and
179
+ * drops structurally malformed entries on library paths. */
180
+ function parseExecutionClaims(raw, sink, knownCriterionRefs) {
210
181
  if (raw === undefined || raw === null)
211
182
  return [];
212
183
  if (!Array.isArray(raw)) {
213
- sink?.push("execution_report.criterion_claims", "not_array", "dropped", "expected an array of claims; the value was dropped");
184
+ sink?.push("execution_report.claims", "not_array", "dropped", "expected an array of { id, outcome } claims; the value was dropped");
214
185
  return [];
215
186
  }
216
187
  const claims = [];
@@ -225,29 +196,30 @@ function parseCriterionClaims(raw, sink, knownCriterionRefs) {
225
196
  continue;
226
197
  }
227
198
  const entry = item;
228
- if (typeof entry.criterion_id !== "string" ||
229
- entry.criterion_id.trim().length === 0 ||
199
+ if (typeof entry.id !== "string" ||
230
200
  (entry.outcome !== "met" && entry.outcome !== "remaining")) {
231
201
  invalid++;
232
202
  continue;
233
203
  }
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");
204
+ const trimmed = entry.id.trim();
205
+ if (trimmed !== entry.id) {
206
+ sink?.push(`execution_report.claims[${index}].id`, "normalized", "normalized", "leading/trailing whitespace was trimmed");
237
207
  }
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");
208
+ if (trimmed.length > 64) {
209
+ sink?.push(`execution_report.claims[${index}].id`, "truncated", "truncated", "longer than 64 chars; truncated to the limit");
240
210
  }
241
- const criterionId = trimmed.slice(0, 500);
242
- if (knownCriterionRefs !== undefined && knownCriterionRefs !== null &&
243
- !knownCriterionRefs.some((criterion) => criterionRefMatches(criterionId, criterion))) {
211
+ const id = trimmed.slice(0, 64);
212
+ if (!CLAIM_ID_RE.test(id)) {
213
+ invalid++;
214
+ continue;
215
+ }
216
+ if (isCriterionClaimId(id) && knownCriterionRefs !== undefined &&
217
+ knownCriterionRefs !== null &&
218
+ !knownCriterionRefs.some((criterion) => criterionRefMatches(id, criterion))) {
244
219
  unknown++;
245
220
  continue;
246
221
  }
247
- claims.push({
248
- criterion_id: criterionId,
249
- outcome: entry.outcome,
250
- });
222
+ claims.push({ id, outcome: entry.outcome });
251
223
  }
252
224
  if (invalid > 0 || unknown > 0) {
253
225
  if (claims.length === 0) {
@@ -256,104 +228,24 @@ function parseCriterionClaims(raw, sink, knownCriterionRefs) {
256
228
  parts.push(`${invalid} malformed`);
257
229
  if (unknown > 0)
258
230
  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)`);
231
+ sink?.push("execution_report.claims", DIAG_ALL_ENTRIES_DROPPED, "dropped", `all ${invalid + unknown} claims were dropped (${parts.join(", ")}; ` +
232
+ "malformed/unreferenced claims never reject a round)");
260
233
  }
261
234
  else {
262
235
  if (invalid > 0) {
263
- sink?.push("execution_report.criterion_claims", "invalid_entry", "dropped", `${invalid} malformed claim${invalid === 1 ? "" : "s"} dropped (advisory)`);
236
+ sink?.push("execution_report.claims", "invalid_entry", "dropped", `${invalid} malformed claim${invalid === 1 ? "" : "s"} dropped`);
264
237
  }
265
238
  if (unknown > 0) {
266
- sink?.push("execution_report.criterion_claims", "unknown_id", "dropped", `${unknown} claim${unknown === 1 ? "" : "s"} cited a criterion the ` +
239
+ sink?.push("execution_report.claims", "unknown_id", "dropped", `${unknown} cr- claim${unknown === 1 ? "" : "s"} cited a criterion the ` +
267
240
  "prompt did not render; dropped (advisory)");
268
241
  }
269
242
  }
270
243
  }
271
244
  if (raw.length > 100) {
272
- sink?.push("execution_report.criterion_claims", "too_many", "truncated", "only the first 100 claims are kept");
273
- }
274
- return claims;
275
- }
276
- /** v3.8: LENIENT parse of contract item claims. Referential validity is the
277
- * strict pre-advance boundary in validateContractShape — this parser only
278
- * normalizes what reaches the read model. */
279
- function parseContractItemClaims(raw, sink) {
280
- if (raw === undefined || raw === null)
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
- }
286
- const claims = [];
287
- let invalid = 0;
288
- for (let index = 0; index < raw.length; index++) {
289
- if (claims.length >= 20)
290
- break;
291
- const item = raw[index];
292
- if (typeof item !== "object" || item === null || Array.isArray(item)) {
293
- invalid++;
294
- continue;
295
- }
296
- const entry = item;
297
- if (typeof entry.item_id !== "string" ||
298
- (entry.outcome !== "met" && entry.outcome !== "remaining")) {
299
- invalid++;
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
- }
309
- claims.push({
310
- item_id: trimmed.slice(0, 64),
311
- outcome: entry.outcome,
312
- });
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");
245
+ sink?.push("execution_report.claims", "too_many", "truncated", "only the first 100 claims are kept");
321
246
  }
322
247
  return claims;
323
248
  }
324
- /** Parse CriterionRevision[] from a raw JSON array. */
325
- export function parseCriterionRevisions(raw, sink) {
326
- if (raw === undefined || raw === null)
327
- return [];
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 &&
333
- typeof v.old === "string" &&
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) => {
345
- const r = v;
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
- };
355
- });
356
- }
357
249
  /** Parse SubGoalUpdate[] from a raw JSON array.
358
250
  * v3.7.1: lenient per-entry filtering (id/status shape) with fixed caps
359
251
  * (20 entries, id ≤ 64, note ≤ 300). Referential validity — unknown IDs
@@ -445,16 +337,12 @@ export function parseWorkerResults(raw, sink) {
445
337
  return valid.slice(0, 20).map((v, index) => {
446
338
  const w = v;
447
339
  for (const key of Object.keys(w)) {
448
- if (!["agentId", "subAgentType", "subTask", "resultSummary", "outcome",
449
- "discoveredConstraints"].includes(key)) {
340
+ if (!["agentId", "subTask", "resultSummary", "outcome"].includes(key)) {
450
341
  sink?.push(`worker_results[${index}].${key}`, "unknown_field", "dropped", "not a declared worker-result field; it was dropped");
451
342
  }
452
343
  }
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
344
  for (const [key, cap] of [
457
- ["agentId", 200], ["subAgentType", 100], ["subTask", 500], ["resultSummary", 1000],
345
+ ["agentId", 200], ["subTask", 500], ["resultSummary", 1000],
458
346
  ]) {
459
347
  const value = w[key];
460
348
  if (typeof value === "string" && value.length > cap) {
@@ -463,56 +351,34 @@ export function parseWorkerResults(raw, sink) {
463
351
  }
464
352
  return {
465
353
  agentId: (w.agentId.slice(0, 200)),
466
- subAgentType: typeof w.subAgentType === "string"
467
- ? w.subAgentType.slice(0, 100)
468
- : "general-purpose",
469
354
  subTask: (w.subTask.slice(0, 500)),
470
355
  resultSummary: (w.resultSummary.slice(0, 1000)),
471
356
  outcome: w.outcome,
472
- discoveredConstraints: boundedStringArray(w.discoveredConstraints, 50, 500, sink, `worker_results[${index}].discoveredConstraints`),
473
357
  };
474
358
  });
475
359
  }
476
- /** v2.12: The effective round outcome — declared outcome wins, otherwise
477
- * derived from `success`. "partial" can only be declared explicitly (never
478
- * silently derived from success=false, which would quietly change the
479
- * success-class enforcement surface). */
480
- export function effectiveOutcome(selfEval) {
481
- if (selfEval.outcome === "success" || selfEval.outcome === "partial" ||
482
- selfEval.outcome === "failed" || selfEval.outcome === "blocked") {
483
- return selfEval.outcome;
484
- }
485
- return selfEval.success ? "success" : "failed";
486
- }
487
- /** v2.12: Whether the effective outcome is a success claim. Shared by the
488
- * verification gate (success-class checks) and enforcement gate (R3). */
489
- export function effectiveSuccess(selfEval) {
490
- return effectiveOutcome(selfEval) === "success";
491
- }
492
- /** v3.8.3: The ONE definition of "this round declares itself blocked".
493
- *
494
- * A declared `outcome: "blocked"` and a `stop_reason` of `blocked` or
495
- * `needs_human_input` say the same thing — the agent is stopping because it
496
- * cannot continue, not because it finished. The stop decision
497
- * (round-coordinator) and the scope-drift waiver (enforcement gate) both read
498
- * this function, so "declared blocked" cannot mean two different things at
499
- * two different boundaries. `effectiveOutcome` alone is NOT this predicate:
500
- * it reads `outcome` only. */
501
- export function declaresBlocked(selfEval) {
502
- return selfEval.outcome === "blocked" ||
503
- selfEval.stop_reason === "blocked" ||
504
- selfEval.stop_reason === "needs_human_input";
505
- }
360
+ // v3.9.7: the outcome predicates live in round-outcome.ts so contract
361
+ // closure, the walker, the verification gate and the coordinator share one
362
+ // definition. Re-exported for the existing public surface.
363
+ export { effectiveOutcome, effectiveSuccess, declaresBlocked, } from "./round-outcome.js";
364
+ export const OBJECTIVE_REFINEMENT_MAX = 1000;
365
+ export const BLOCKER_MAX = 500;
506
366
  export function validateCoreSelfEvaluation(raw) {
507
367
  const missing = [];
508
368
  const invalid = [];
509
- const required = ["success", "output_summary", "constraint_violations", "should_continue"];
369
+ const required = ["outcome", "output_summary", "constraint_violations", "should_continue"];
510
370
  for (const field of required) {
511
371
  if (!(field in raw))
512
372
  missing.push(field);
513
373
  }
514
- if ("success" in raw && typeof raw.success !== "boolean") {
515
- invalid.push({ field: "success", expected: "boolean" });
374
+ const outcome = raw.outcome;
375
+ if ("outcome" in raw &&
376
+ (outcome !== "success" && outcome !== "partial" &&
377
+ outcome !== "failed" && outcome !== "blocked")) {
378
+ invalid.push({
379
+ field: "outcome",
380
+ expected: "success | partial | failed | blocked",
381
+ });
516
382
  }
517
383
  if ("output_summary" in raw && typeof raw.output_summary !== "string") {
518
384
  invalid.push({ field: "output_summary", expected: "string" });
@@ -525,13 +391,56 @@ export function validateCoreSelfEvaluation(raw) {
525
391
  if ("should_continue" in raw && typeof raw.should_continue !== "boolean") {
526
392
  invalid.push({ field: "should_continue", expected: "boolean" });
527
393
  }
394
+ // v4.0.6: strict outcome/blocker combinations. A non-blocked blocker is NOT
395
+ // rejected — it is dropped with a diagnostic by the parser.
396
+ if (outcome === "success" && raw.should_continue === true) {
397
+ invalid.push({
398
+ field: "outcome",
399
+ expected: "outcome=success requires should_continue=false",
400
+ });
401
+ }
402
+ if (outcome === "blocked") {
403
+ if (raw.should_continue === true) {
404
+ invalid.push({
405
+ field: "outcome",
406
+ expected: "outcome=blocked requires should_continue=false",
407
+ });
408
+ }
409
+ if (typeof raw.blocker !== "string" || raw.blocker.trim().length === 0) {
410
+ invalid.push({
411
+ field: "blocker",
412
+ expected: "a non-empty blocker string is required when outcome=blocked",
413
+ });
414
+ }
415
+ else if (raw.blocker.length > BLOCKER_MAX) {
416
+ invalid.push({
417
+ field: "blocker",
418
+ expected: `a string of at most ${BLOCKER_MAX} chars when outcome=blocked`,
419
+ });
420
+ }
421
+ }
422
+ if (raw.objective_refinement !== undefined) {
423
+ const refinement = raw.objective_refinement;
424
+ if (typeof refinement !== "string" || refinement.trim().length === 0) {
425
+ invalid.push({
426
+ field: "objective_refinement",
427
+ expected: "a non-empty string",
428
+ });
429
+ }
430
+ else if (refinement.length > OBJECTIVE_REFINEMENT_MAX) {
431
+ invalid.push({
432
+ field: "objective_refinement",
433
+ expected: `a string of at most ${OBJECTIVE_REFINEMENT_MAX} chars`,
434
+ });
435
+ }
436
+ }
528
437
  return { missing, invalid };
529
438
  }
530
439
  /** v3.7.1: Strict structural checks for the sub-goal protocol fields
531
440
  * (subgoal_updates / emerged_subtasks). Runs in the same pre-advance
532
441
  * evaluation_invalid boundary as the four core fields: an error here is a
533
442
  * payload defect, never a work-quality rejection — no session state, no
534
- * gates, no rejection counters, retry with the same roundId.
443
+ * gates, no rejection counters, retry with the same submissionHandle.
535
444
  *
536
445
  * Shape-only (no vault): entry shape, id pattern, status enum, caps, and
537
446
  * the sg-XXXXXXXX creation prohibition on emerged_subtasks. Referential
@@ -606,47 +515,51 @@ export function validateSubGoalUpdatesShape(raw) {
606
515
  });
607
516
  return errors;
608
517
  }
609
- const SUBGOAL_ID_RE = /^sg-[a-f0-9]{8}$/;
610
- /** v3.8: `scope` is the round's declared file boundary, so containment is a
611
- * trust boundary like every other workspace path — a declaration that names
612
- * `/etc` or `../outside` is rejected, not silently normalized into a string
613
- * the drift check can never match. */
614
- function validateContractScope(scope, checkScopeEntry, errors) {
615
- if (scope === undefined)
616
- return;
617
- if (!Array.isArray(scope)) {
618
- errors.push({
619
- field: "round_contract",
620
- reason: "invalid_scope",
621
- detail: "scope must be an array of workspace-relative paths",
622
- });
623
- return;
518
+ const CONTRACT_ID_RE = /^rc-[a-f0-9]{8}$/;
519
+ export const CONTRACT_SUPERSEDE_REASON_MAX = 500;
520
+ function declaresBlockedRaw(raw) {
521
+ return raw.outcome === "blocked";
522
+ }
523
+ /** v4.0.6: LENIENT parser for the `supersedes` modifier inside a committed
524
+ * Round Contract proposal. Historical/externally written data produces a
525
+ * diagnostic and is dropped; it never closes a contract and never throws.
526
+ * The strict submission boundary is the only commit check. */
527
+ export function parseContractSupersede(raw, sink, path = "round_contract.supersedes") {
528
+ if (raw === undefined)
529
+ return undefined;
530
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
531
+ sink?.push(path, "invalid_entry", "dropped", "expected a { contract_id, reason? } object; the value was dropped");
532
+ return undefined;
624
533
  }
625
- if (scope.length > CONTRACT_LIMITS.scope) {
626
- errors.push({
627
- field: "round_contract",
628
- reason: "too_many_scope_entries",
629
- detail: `scope may list at most ${CONTRACT_LIMITS.scope} entries (got ${scope.length})`,
630
- });
534
+ const obj = raw;
535
+ for (const key of Object.keys(obj)) {
536
+ if (key !== "contract_id" && key !== "reason") {
537
+ sink?.push(`${path}.${key}`, "unknown_field", "dropped", "not a declared supersedes field; it was dropped");
538
+ }
631
539
  }
632
- scope.forEach((entry, index) => {
633
- if (typeof entry !== "string" || entry.trim().length === 0) {
634
- errors.push({
635
- field: "round_contract",
636
- reason: "invalid_scope_entry",
637
- detail: `scope entry ${index} must be a non-empty string`,
638
- });
639
- return;
540
+ if (typeof obj.contract_id !== "string" ||
541
+ !CONTRACT_ID_RE.test(obj.contract_id.trim())) {
542
+ sink?.push(path, "invalid_id", "dropped", "contract_id must be an rc-XXXXXXXX id; the redirect was dropped");
543
+ return undefined;
544
+ }
545
+ const contractId = obj.contract_id.trim();
546
+ let reason;
547
+ if (obj.reason !== undefined) {
548
+ if (typeof obj.reason !== "string") {
549
+ sink?.push(`${path}.reason`, "invalid_value", "dropped", "expected a string; the reason was dropped");
640
550
  }
641
- const detail = checkScopeEntry(entry.trim());
642
- if (detail) {
643
- errors.push({
644
- field: "round_contract",
645
- reason: "scope_outside_workspace",
646
- detail: `scope entry ${index} "${entry.trim()}" ${detail}`,
647
- });
551
+ else if (obj.reason.length > CONTRACT_SUPERSEDE_REASON_MAX) {
552
+ sink?.push(`${path}.reason`, "truncated", "truncated", `longer than ${CONTRACT_SUPERSEDE_REASON_MAX} chars; truncated to the limit`);
553
+ reason = obj.reason.slice(0, CONTRACT_SUPERSEDE_REASON_MAX);
648
554
  }
649
- });
555
+ else {
556
+ reason = obj.reason;
557
+ }
558
+ }
559
+ return {
560
+ contract_id: contractId,
561
+ ...(reason !== undefined ? { reason } : {}),
562
+ };
650
563
  }
651
564
  function validateContractItem(item, index, context, errors) {
652
565
  if (typeof item !== "object" || item === null || Array.isArray(item)) {
@@ -658,6 +571,15 @@ function validateContractItem(item, index, context, errors) {
658
571
  return;
659
572
  }
660
573
  const entry = item;
574
+ for (const key of Object.keys(entry)) {
575
+ if (!["description", "criterion_refs", "verify_with"].includes(key)) {
576
+ errors.push({
577
+ field: "round_contract",
578
+ reason: "unknown_field",
579
+ detail: `item ${index} has unknown field "${key}"`,
580
+ });
581
+ }
582
+ }
661
583
  if (typeof entry.description !== "string" || entry.description.trim().length === 0) {
662
584
  errors.push({
663
585
  field: "round_contract",
@@ -665,6 +587,14 @@ function validateContractItem(item, index, context, errors) {
665
587
  detail: `item ${index} needs a non-empty description`,
666
588
  });
667
589
  }
590
+ else if (entry.description.trim().length > CONTRACT_LIMITS.itemDescription) {
591
+ errors.push({
592
+ field: "round_contract",
593
+ reason: "item_description_too_long",
594
+ detail: `item ${index} description may be at most ` +
595
+ `${CONTRACT_LIMITS.itemDescription} chars`,
596
+ });
597
+ }
668
598
  const verifyWith = entry.verify_with;
669
599
  if (!Array.isArray(verifyWith) || verifyWith.length === 0) {
670
600
  errors.push({
@@ -711,62 +641,155 @@ function validateContractItem(item, index, context, errors) {
711
641
  `${CONTRACT_LIMITS.criterionRefs} ids (got ${criterionRefs.length})`,
712
642
  });
713
643
  }
644
+ const seen = new Set();
714
645
  criterionRefs.forEach((ref, refIndex) => {
715
- if (typeof ref !== "string" || ref.trim().length === 0) {
646
+ if (typeof ref !== "string" || !isCriterionId(ref.trim())) {
716
647
  errors.push({
717
648
  field: "round_contract",
718
649
  reason: "invalid_criterion_ref",
719
- detail: `item ${index} criterion_refs entry ${refIndex} must be a non-empty string`,
650
+ detail: `item ${index} criterion_refs entry ${refIndex} must be a cr-XXXXXXXX id`,
720
651
  });
721
- }
722
- });
723
- }
724
- }
725
- const subgoalRefs = entry.subgoal_refs;
726
- if (subgoalRefs !== undefined) {
727
- if (!Array.isArray(subgoalRefs)) {
728
- errors.push({
729
- field: "round_contract",
730
- reason: "invalid_subgoal_refs",
731
- detail: `item ${index} subgoal_refs must be an array of sg-XXXXXXXX ids`,
732
- });
733
- }
734
- else {
735
- if (subgoalRefs.length > CONTRACT_LIMITS.subgoalRefs) {
736
- errors.push({
737
- field: "round_contract",
738
- reason: "too_many_subgoal_refs",
739
- detail: `item ${index} subgoal_refs may list at most ` +
740
- `${CONTRACT_LIMITS.subgoalRefs} ids (got ${subgoalRefs.length})`,
741
- });
742
- }
743
- const seen = new Set();
744
- for (const ref of subgoalRefs) {
745
- if (typeof ref !== "string" || !SUBGOAL_ID_RE.test(ref.trim())) {
746
- errors.push({
747
- field: "round_contract",
748
- reason: "invalid_subgoal_ref",
749
- detail: `item ${index} subgoal_refs entry "${String(ref)}" must be an sg-XXXXXXXX id`,
750
- });
751
- continue;
652
+ return;
752
653
  }
753
654
  const id = ref.trim();
754
655
  if (seen.has(id)) {
755
656
  errors.push({
756
657
  field: "round_contract",
757
- reason: "duplicate_subgoal_ref",
758
- detail: `item ${index} subgoal_refs repeats ${id}`,
658
+ reason: "duplicate_criterion_ref",
659
+ detail: `item ${index} criterion_refs repeats ${id}`,
759
660
  });
760
661
  }
761
662
  seen.add(id);
762
- if (context.knownSubGoalIds && !context.knownSubGoalIds.has(id)) {
663
+ if (context.knownCriterionIds && !context.knownCriterionIds.has(id)) {
763
664
  errors.push({
764
665
  field: "round_contract",
765
- reason: "unknown_subgoal_ref",
766
- detail: `item ${index} references ${id}, which is not a sub-goal of this loop`,
666
+ reason: "unknown_criterion_ref",
667
+ detail: `item ${index} references ${id}, which is not an active criterion of this loop`,
767
668
  });
768
669
  }
769
- }
670
+ });
671
+ }
672
+ }
673
+ }
674
+ /** v3.9.4: the normalized content identity of a RAW proposal item. Malformed
675
+ * items return null (their own shape errors already fire); valid items use
676
+ * the same canonical text as `deriveContractItemIds`. */
677
+ function rawContractItemIdentity(item) {
678
+ if (typeof item !== "object" || item === null || Array.isArray(item))
679
+ return null;
680
+ const entry = item;
681
+ const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
682
+ return canonicalContractItemText({
683
+ description: typeof entry.description === "string" ? entry.description : "",
684
+ criterion_refs: strings(entry.criterion_refs),
685
+ verify_with: strings(entry.verify_with),
686
+ });
687
+ }
688
+ /** v4.0.6: strict `round_contract.supersedes` boundary. Every failure is
689
+ * `contract_invalid`; the same submissionHandle may retry with a corrected
690
+ * payload. The rules migrated from the former standalone
691
+ * `contract_transition` field:
692
+ * - the target must exist and equal the ACTIVE contract id;
693
+ * - the successor (this same proposal) must carry different acceptance items;
694
+ * - a redirect cannot be combined with a blocked outcome or a stop.
695
+ * `scope`/`work_item` differences alone are not a new slice. */
696
+ function validateContractSupersede(raw, proposal, context, errors) {
697
+ const value = proposal.supersedes;
698
+ if (value === undefined)
699
+ return;
700
+ const field = "round_contract.supersedes";
701
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
702
+ errors.push({
703
+ field,
704
+ reason: "not_object",
705
+ detail: "round_contract.supersedes must be a { contract_id, reason? } object",
706
+ });
707
+ return;
708
+ }
709
+ const obj = value;
710
+ for (const key of Object.keys(obj)) {
711
+ if (key !== "contract_id" && key !== "reason") {
712
+ errors.push({
713
+ field,
714
+ reason: "unknown_field",
715
+ detail: `round_contract.supersedes has unknown field "${key}"`,
716
+ });
717
+ }
718
+ }
719
+ const contractId = typeof obj.contract_id === "string"
720
+ ? obj.contract_id.trim()
721
+ : "";
722
+ if (!CONTRACT_ID_RE.test(contractId)) {
723
+ errors.push({
724
+ field,
725
+ reason: "invalid_contract_id",
726
+ detail: "round_contract.supersedes.contract_id must be an rc-XXXXXXXX id",
727
+ });
728
+ }
729
+ if (obj.reason !== undefined &&
730
+ (typeof obj.reason !== "string" ||
731
+ obj.reason.length > CONTRACT_SUPERSEDE_REASON_MAX)) {
732
+ errors.push({
733
+ field,
734
+ reason: "invalid_reason",
735
+ detail: `round_contract.supersedes.reason must be a string of at most ` +
736
+ `${CONTRACT_SUPERSEDE_REASON_MAX} chars`,
737
+ });
738
+ }
739
+ if (declaresBlockedRaw(raw)) {
740
+ errors.push({
741
+ field,
742
+ reason: "supersede_conflicts_with_blocked",
743
+ detail: "a contract redirect cannot be combined with a blocked outcome",
744
+ });
745
+ }
746
+ if (raw.should_continue === false) {
747
+ errors.push({
748
+ field,
749
+ reason: "supersede_on_stop",
750
+ detail: "a contract redirect activates a successor next round; it cannot be submitted with should_continue=false",
751
+ });
752
+ }
753
+ if (context.observed === false)
754
+ return;
755
+ if (context.activeContractId === undefined)
756
+ return;
757
+ if (context.activeContractId === null) {
758
+ errors.push({
759
+ field,
760
+ reason: "no_active_contract",
761
+ detail: "there is no active contract to supersede",
762
+ });
763
+ return;
764
+ }
765
+ if (contractId !== context.activeContractId) {
766
+ errors.push({
767
+ field,
768
+ reason: "supersede_target_mismatch",
769
+ detail: `round_contract.supersedes.contract_id must equal the active contract ` +
770
+ `(${context.activeContractId})`,
771
+ });
772
+ }
773
+ // "Substantive change" means the ACCEPTANCE CONTENT changed. A new
774
+ // work_item/scope label alone is not a new slice.
775
+ const activeIdentities = context.activeItemIdentities;
776
+ const rawItems = proposal.items;
777
+ if (activeIdentities && Array.isArray(rawItems)) {
778
+ const identities = new Set();
779
+ for (const item of rawItems) {
780
+ const identity = rawContractItemIdentity(item);
781
+ if (identity !== null)
782
+ identities.add(identity);
783
+ }
784
+ if (identities.size > 0 &&
785
+ identities.size === activeIdentities.size &&
786
+ [...identities].every((identity) => activeIdentities.has(identity))) {
787
+ errors.push({
788
+ field,
789
+ reason: "supersede_without_change",
790
+ detail: "the successor's acceptance items are identical to the active " +
791
+ "contract's; changing only work_item/scope is not a new slice",
792
+ });
770
793
  }
771
794
  }
772
795
  }
@@ -774,13 +797,14 @@ function validateContractItem(item, index, context, errors) {
774
797
  * the contract item claims — the same pre-advance contract as
775
798
  * subgoal_updates: an error here is a payload defect, never a work-quality
776
799
  * rejection (no session state, no gates, no rejection counters, retry with
777
- * the same roundId as `contract_invalid`).
800
+ * the same submissionHandle as `contract_invalid`).
778
801
  *
779
802
  * Declaration strictness: items must exist and stay within CONTRACT_LIMITS;
780
803
  * every item must bind at least one configured, enabled, after-capable
781
- * command; scope entries must stay inside the workspace; criterion and
782
- * sub-goal references must be well-formed, and sub-goal references must name
783
- * a sub-goal that actually exists. The limits are enforced HERE rather than
804
+ * command; criterion and sub-goal references must be well-formed, and
805
+ * sub-goal references must name a sub-goal that actually exists. `scope` is
806
+ * deliberately NOT part of this boundary (v3.9.9): it is optional,
807
+ * report-only intent and is normalized leniently. The limits are enforced HERE rather than
784
808
  * by silently truncating in the lenient parser — a declaration is a
785
809
  * boundary, not a suggestion.
786
810
  *
@@ -799,7 +823,47 @@ export function validateContractShape(raw, context) {
799
823
  }
800
824
  else {
801
825
  const obj = contract;
802
- validateContractScope(obj.scope, context.checkScopeEntry, errors);
826
+ for (const key of Object.keys(obj)) {
827
+ if (!["work_item", "scope", "supersedes", "items"].includes(key)) {
828
+ errors.push({
829
+ field: "round_contract",
830
+ reason: "unknown_field",
831
+ detail: `round_contract has unknown field "${key}"`,
832
+ });
833
+ }
834
+ }
835
+ if (obj.work_item === undefined) {
836
+ errors.push({
837
+ field: "round_contract",
838
+ reason: "missing_work_item",
839
+ detail: "a contract must name the deliverable in work_item",
840
+ });
841
+ }
842
+ else if (typeof obj.work_item !== "string") {
843
+ errors.push({
844
+ field: "round_contract",
845
+ reason: "invalid_work_item",
846
+ detail: "work_item must be a string",
847
+ });
848
+ }
849
+ else if (obj.work_item.trim().length === 0) {
850
+ errors.push({
851
+ field: "round_contract",
852
+ reason: "empty_work_item",
853
+ detail: "work_item must not be empty",
854
+ });
855
+ }
856
+ else if (obj.work_item.trim().length > CONTRACT_LIMITS.workItem) {
857
+ errors.push({
858
+ field: "round_contract",
859
+ reason: "work_item_too_long",
860
+ detail: `work_item may be at most ${CONTRACT_LIMITS.workItem} chars`,
861
+ });
862
+ }
863
+ // v3.9.9: `scope` is optional, report-only intent. It is normalized
864
+ // leniently by parseRoundContract (trim, cap, drop non-strings) and no
865
+ // longer participates in this strict boundary — no required key, no
866
+ // workspace containment, no whole-contract rejection.
803
867
  const items = obj.items;
804
868
  if (!Array.isArray(items) || items.length === 0) {
805
869
  errors.push({
@@ -816,21 +880,42 @@ export function validateContractShape(raw, context) {
816
880
  });
817
881
  }
818
882
  else {
883
+ const seenItems = new Map();
819
884
  items.forEach((item, index) => {
820
885
  validateContractItem(item, index, context, errors);
886
+ // v3.9.4: two identical normalized items would derive two rci- ids
887
+ // for one semantic object. Reject the restatement instead of
888
+ // letting a citation become ambiguous.
889
+ const identity = rawContractItemIdentity(item);
890
+ if (identity === null)
891
+ return;
892
+ const first = seenItems.get(identity);
893
+ if (first !== undefined) {
894
+ errors.push({
895
+ field: "round_contract",
896
+ reason: "duplicate_contract_item",
897
+ detail: `item ${index} duplicates item ${first} by normalized content`,
898
+ });
899
+ return;
900
+ }
901
+ seenItems.set(identity, index);
821
902
  });
822
903
  }
904
+ validateContractSupersede(raw, obj, context, errors);
823
905
  }
824
906
  }
907
+ // v4.0.6: `execution_report.claims` is the ONE claims channel. Only `rci-`
908
+ // entries are STRICT here (format, uniqueness, ACTIVE item membership);
909
+ // `cr-` entries stay advisory and are normalized by the lenient parser.
825
910
  const report = raw.execution_report;
826
911
  if (typeof report === "object" && report !== null && !Array.isArray(report)) {
827
- const claims = report.contract_item_claims;
912
+ const claims = report.claims;
828
913
  if (claims !== undefined) {
829
914
  if (!Array.isArray(claims)) {
830
915
  errors.push({
831
- field: "execution_report.contract_item_claims",
916
+ field: "execution_report.claims",
832
917
  reason: "not_array",
833
- detail: "contract_item_claims must be an array of { item_id, outcome } entries",
918
+ detail: "execution_report.claims must be an array of { id, outcome } entries",
834
919
  });
835
920
  }
836
921
  else {
@@ -838,41 +923,44 @@ export function validateContractShape(raw, context) {
838
923
  claims.forEach((claim, index) => {
839
924
  if (typeof claim !== "object" || claim === null || Array.isArray(claim)) {
840
925
  errors.push({
841
- field: "execution_report.contract_item_claims",
926
+ field: "execution_report.claims",
842
927
  reason: "invalid_entry",
843
928
  detail: `entry ${index} is not an object`,
844
929
  });
845
930
  return;
846
931
  }
847
932
  const entry = claim;
848
- const id = typeof entry.item_id === "string" ? entry.item_id.trim() : "";
849
- if (!/^rci-[a-f0-9]{8}$/.test(id)) {
850
- errors.push({
851
- field: "execution_report.contract_item_claims",
852
- reason: "invalid_item_id",
853
- detail: `entry ${index} must reference an rci-XXXXXXXX contract item id`,
854
- });
855
- }
856
- else {
857
- if (seen.has(id)) {
933
+ const rawId = typeof entry.id === "string" ? entry.id.trim() : "";
934
+ const strictItem = rawId.startsWith("rci-");
935
+ if (strictItem) {
936
+ if (!/^rci-[a-f0-9]{8}$/.test(rawId)) {
858
937
  errors.push({
859
- field: "execution_report.contract_item_claims",
860
- reason: "duplicate_item_id",
861
- detail: `entry ${index} repeats item id ${id}`,
938
+ field: "execution_report.claims",
939
+ reason: "invalid_item_id",
940
+ detail: `entry ${index} must reference an rci-XXXXXXXX contract item id`,
862
941
  });
863
942
  }
864
- seen.add(id);
865
- if (context.activeItemIds && !context.activeItemIds.has(id)) {
866
- errors.push({
867
- field: "execution_report.contract_item_claims",
868
- reason: "unknown_item_id",
869
- detail: `entry ${index} references ${id}, which is not an item of the ACTIVE contract`,
870
- });
943
+ else {
944
+ if (seen.has(rawId)) {
945
+ errors.push({
946
+ field: "execution_report.claims",
947
+ reason: "duplicate_item_id",
948
+ detail: `entry ${index} repeats item id ${rawId}`,
949
+ });
950
+ }
951
+ seen.add(rawId);
952
+ if (context.activeItemIds && !context.activeItemIds.has(rawId)) {
953
+ errors.push({
954
+ field: "execution_report.claims",
955
+ reason: "unknown_item_id",
956
+ detail: `entry ${index} references ${rawId}, which is not an item of the ACTIVE contract`,
957
+ });
958
+ }
871
959
  }
872
960
  }
873
961
  if (entry.outcome !== "met" && entry.outcome !== "remaining") {
874
962
  errors.push({
875
- field: "execution_report.contract_item_claims",
963
+ field: "execution_report.claims",
876
964
  reason: "invalid_outcome",
877
965
  detail: `entry ${index} outcome must be "met" or "remaining"`,
878
966
  });
@@ -891,12 +979,18 @@ export function validateContractShape(raw, context) {
891
979
  export function buildSelfEvaluation(raw, sink, facts) {
892
980
  const executionReport = parseExecutionReport(raw.execution_report, sink, facts?.knownCriterionRefs);
893
981
  const retractedConstraints = boundedStringArray(raw.retracted_constraints, 50, 500, sink, "retracted_constraints");
894
- const revisedCriteria = parseCriterionRevisions(raw.revised_success_criteria, sink);
895
982
  const wrongAssumptions = boundedStringArray(raw.wrong_assumptions, 50, 500, sink, "wrong_assumptions");
983
+ // v4.0.2: strictly report-only. The window/caps are the field's contract,
984
+ // not policy: at most 3 entries, 300 chars each.
985
+ const openQuestions = boundedStringArray(raw.open_questions, 3, 300, sink, "open_questions");
986
+ const resolvedOpenQuestionIds = boundedStringArray(raw.resolved_open_question_ids, 20, 80, sink, "resolved_open_question_ids")
987
+ .filter((value) => {
988
+ if (isOpenQuestionId(value))
989
+ return true;
990
+ sink?.push("resolved_open_question_ids", "invalid_value", "dropped", `invalid open-question id: ${value}`);
991
+ return false;
992
+ });
896
993
  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
994
  if (raw.output_summary !== undefined && typeof raw.output_summary !== "string") {
901
995
  sink?.push("output_summary", "invalid_value", "dropped", "expected a string; normalized to the empty string (core validation rejects it)");
902
996
  }
@@ -919,29 +1013,38 @@ export function buildSelfEvaluation(raw, sink, facts) {
919
1013
  if (raw.should_continue !== undefined && typeof raw.should_continue !== "boolean") {
920
1014
  sink?.push("should_continue", "invalid_value", "dropped", "expected a boolean; normalized to true (core validation rejects it)");
921
1015
  }
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
1016
  if (raw.outcome !== undefined &&
928
1017
  raw.outcome !== "success" && raw.outcome !== "partial" &&
929
1018
  raw.outcome !== "failed" && raw.outcome !== "blocked") {
930
- sink?.push("outcome", "invalid_enum", "dropped", "expected success | partial | failed | blocked; the value was dropped");
1019
+ sink?.push("outcome", "invalid_enum", "dropped", "expected success | partial | failed | blocked; the value was dropped (core validation rejects it)");
1020
+ }
1021
+ if (raw.objective_refinement !== undefined) {
1022
+ if (typeof raw.objective_refinement !== "string") {
1023
+ sink?.push("objective_refinement", "invalid_value", "dropped", "expected a string; the value was dropped (core validation rejects it)");
1024
+ }
1025
+ else if (raw.objective_refinement.trim().length === 0) {
1026
+ sink?.push("objective_refinement", DIAG_EMPTY_VALUE, "dropped", "empty objective_refinement was dropped (core validation rejects it)");
1027
+ }
1028
+ else if (raw.objective_refinement.length > OBJECTIVE_REFINEMENT_MAX) {
1029
+ sink?.push("objective_refinement", "truncated", "truncated", `longer than ${OBJECTIVE_REFINEMENT_MAX} chars; truncated to the limit (core validation rejects it)`);
1030
+ }
931
1031
  }
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");
1032
+ if (raw.subtask_boundary !== undefined &&
1033
+ typeof raw.subtask_boundary !== "boolean") {
1034
+ sink?.push("subtask_boundary", "invalid_value", "dropped", "expected a boolean; normalized to false");
935
1035
  }
936
1036
  if (raw.blocker !== undefined) {
937
- if (typeof raw.blocker !== "string") {
938
- sink?.push("blocker", "invalid_value", "dropped", "expected a string; the value was dropped");
1037
+ if (raw.outcome !== "blocked") {
1038
+ sink?.push("blocker", "not_applicable", "dropped", `blocker is meaningful only when outcome=blocked (outcome=${typeof raw.outcome === "string" ? raw.outcome : "missing"}); the value was dropped`);
1039
+ }
1040
+ else if (typeof raw.blocker !== "string") {
1041
+ sink?.push("blocker", "invalid_value", "dropped", "expected a string; the value was dropped (core validation rejects it)");
939
1042
  }
940
1043
  else if (raw.blocker.trim().length === 0) {
941
- sink?.push("blocker", DIAG_EMPTY_VALUE, "dropped", "empty blocker was dropped");
1044
+ sink?.push("blocker", DIAG_EMPTY_VALUE, "dropped", "empty blocker was dropped (core validation rejects it)");
942
1045
  }
943
- else if (raw.blocker.length > 500) {
944
- sink?.push("blocker", "truncated", "truncated", "longer than 500 chars; truncated to the limit");
1046
+ else if (raw.blocker.length > BLOCKER_MAX) {
1047
+ sink?.push("blocker", "truncated", "truncated", `longer than ${BLOCKER_MAX} chars; truncated to the limit (core validation rejects it)`);
945
1048
  }
946
1049
  }
947
1050
  const noChangeReason = boundedString(raw.no_change_reason, 200, sink, "no_change_reason");
@@ -949,35 +1052,35 @@ export function buildSelfEvaluation(raw, sink, facts) {
949
1052
  sink?.push("no_change_reason", DIAG_EMPTY_VALUE, "dropped", "empty reason was dropped");
950
1053
  }
951
1054
  return makeSelfEvaluation({
952
- success: typeof raw.success === "boolean" ? raw.success : false,
1055
+ outcome: raw.outcome === "success" || raw.outcome === "partial" ||
1056
+ raw.outcome === "failed" || raw.outcome === "blocked"
1057
+ ? raw.outcome
1058
+ : "failed",
953
1059
  output_summary: typeof raw.output_summary === "string" ? raw.output_summary : "",
954
1060
  constraint_violations: constraintViolations,
955
1061
  should_continue: typeof raw.should_continue === "boolean" ? raw.should_continue : true,
956
- discovered_constraints: boundedStringArray(raw.discovered_constraints, 50, 500, sink, "discovered_constraints"),
957
- objective_refinement: boundedString(raw.objective_refinement, 1000, sink, "objective_refinement") ?? "",
1062
+ discovered_constraints: boundedStringArray(raw.discovered_constraints, facts?.maxDiscoveredConstraints ?? 50, 500, sink, "discovered_constraints"),
958
1063
  emerged_subtasks: boundedEmergedSubtasks(raw.emerged_subtasks, sink),
959
1064
  execution_report: executionReport,
960
1065
  retracted_constraints: retractedConstraints,
961
- revised_success_criteria: revisedCriteria,
962
1066
  wrong_assumptions: wrongAssumptions,
1067
+ open_questions: openQuestions,
1068
+ resolved_open_question_ids: resolvedOpenQuestionIds,
963
1069
  worker_results: workerResults,
964
- compression_checkpoint: typeof raw.compression_checkpoint === "boolean" ? raw.compression_checkpoint : false,
965
- checkpoint_label: boundedString(raw.checkpoint_label, 200, sink, "checkpoint_label") ?? "",
1070
+ subtask_boundary: typeof raw.subtask_boundary === "boolean" ? raw.subtask_boundary : false,
1071
+ subtask_boundary_label: boundedString(raw.subtask_boundary_label, 200, sink, "subtask_boundary_label") ?? "",
966
1072
  subgoal_updates: parseSubGoalUpdates(raw.subgoal_updates, sink),
967
- stop_reason: raw.stop_reason === "gave_up" || raw.stop_reason === "blocked" || raw.stop_reason === "needs_human_input"
968
- ? raw.stop_reason
969
- : undefined,
970
- outcome: raw.outcome === "success" || raw.outcome === "partial" ||
971
- raw.outcome === "failed" || raw.outcome === "blocked"
972
- ? raw.outcome
1073
+ objective_refinement: typeof raw.objective_refinement === "string" &&
1074
+ raw.objective_refinement.trim().length > 0
1075
+ ? raw.objective_refinement.trim().slice(0, OBJECTIVE_REFINEMENT_MAX)
973
1076
  : undefined,
974
- blocker: typeof raw.blocker === "string" && raw.blocker.trim().length > 0
975
- ? raw.blocker.slice(0, 500)
1077
+ blocker: raw.outcome === "blocked" &&
1078
+ typeof raw.blocker === "string" && raw.blocker.trim().length > 0
1079
+ ? raw.blocker.slice(0, BLOCKER_MAX)
976
1080
  : undefined,
977
1081
  gate_ids: boundedStringArray(raw.gate_ids, 20, 64, sink, "gate_ids"),
978
1082
  retroactiveClaims: parseRetroactiveClaims(raw.retroactiveClaims, sink),
979
1083
  no_change_reason: noChangeReason || undefined,
980
- prompt_requests: parsePromptRequests(raw.prompt_requests, sink),
981
1084
  round_contract: parseRoundContract(raw.round_contract, sink),
982
1085
  });
983
1086
  }
@@ -1000,10 +1103,11 @@ export function normalizeSelfEvaluation(raw, facts) {
1000
1103
  * (the lenient path, reached only after the strict boundary passed) truncates
1001
1104
  * to the same numbers. One source, so the two can never drift. */
1002
1105
  export const CONTRACT_LIMITS = {
1106
+ workItem: 200,
1107
+ itemDescription: 200,
1003
1108
  items: 20,
1004
1109
  scope: 50,
1005
1110
  criterionRefs: 20,
1006
- subgoalRefs: 20,
1007
1111
  verifyWith: 20,
1008
1112
  };
1009
1113
  /** Parse a Round Contract PROPOSAL from raw JSON input. Lenient on shapes
@@ -1024,7 +1128,7 @@ export function parseRoundContract(raw, sink) {
1024
1128
  }
1025
1129
  const obj = raw;
1026
1130
  for (const key of Object.keys(obj)) {
1027
- if (!["work_item", "scope", "items"].includes(key)) {
1131
+ if (!["work_item", "scope", "supersedes", "items"].includes(key)) {
1028
1132
  sink?.push(`round_contract.${key}`, "unknown_field", "dropped", "not a declared contract field; it was dropped");
1029
1133
  }
1030
1134
  }
@@ -1074,7 +1178,7 @@ export function parseRoundContract(raw, sink) {
1074
1178
  .slice(0, CONTRACT_LIMITS.items)
1075
1179
  .map((item, index) => {
1076
1180
  for (const key of Object.keys(item)) {
1077
- if (!["description", "criterion_refs", "subgoal_refs", "verify_with"].includes(key)) {
1181
+ if (!["description", "criterion_refs", "verify_with"].includes(key)) {
1078
1182
  sink?.push(`round_contract.items[${index}].${key}`, "unknown_field", "dropped", "not a declared contract-item field; it was dropped");
1079
1183
  }
1080
1184
  }
@@ -1082,15 +1186,14 @@ export function parseRoundContract(raw, sink) {
1082
1186
  sink?.push(`round_contract.items[${index}].description`, "invalid_value", "dropped", "expected a string; the description was dropped");
1083
1187
  }
1084
1188
  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");
1189
+ item.description.trim().length > CONTRACT_LIMITS.itemDescription) {
1190
+ sink?.push(`round_contract.items[${index}].description`, "truncated", "truncated", `longer than ${CONTRACT_LIMITS.itemDescription} chars; truncated to the limit`);
1087
1191
  }
1088
1192
  return {
1089
1193
  description: typeof item.description === "string"
1090
- ? item.description.trim().slice(0, 200)
1194
+ ? item.description.trim().slice(0, CONTRACT_LIMITS.itemDescription)
1091
1195
  : "",
1092
1196
  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
1197
  verify_with: capStrings(item.verify_with, 200, CONTRACT_LIMITS.verifyWith, `round_contract.items[${index}].verify_with`),
1095
1198
  };
1096
1199
  });
@@ -1107,52 +1210,20 @@ export function parseRoundContract(raw, sink) {
1107
1210
  else if (typeof obj.work_item === "string" && obj.work_item.trim().length === 0) {
1108
1211
  sink?.push("round_contract.work_item", DIAG_EMPTY_VALUE, "dropped", "empty work_item was dropped");
1109
1212
  }
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");
1213
+ else if (typeof obj.work_item === "string" &&
1214
+ obj.work_item.trim().length > CONTRACT_LIMITS.workItem) {
1215
+ sink?.push("round_contract.work_item", "truncated", "truncated", `longer than ${CONTRACT_LIMITS.workItem} chars; truncated to the limit`);
1112
1216
  }
1217
+ const supersedes = parseContractSupersede(obj.supersedes, sink);
1113
1218
  return {
1114
1219
  work_item: typeof obj.work_item === "string" && obj.work_item.trim().length > 0
1115
- ? obj.work_item.trim().slice(0, 200)
1220
+ ? obj.work_item.trim().slice(0, CONTRACT_LIMITS.workItem)
1116
1221
  : undefined,
1117
1222
  scope: capStrings(obj.scope, 500, CONTRACT_LIMITS.scope, "round_contract.scope"),
1223
+ ...(supersedes !== undefined ? { supersedes } : {}),
1118
1224
  items,
1119
1225
  };
1120
1226
  }
1121
- /** Parse a PromptRequests object from raw JSON input.
1122
- * Lenient: missing or invalid fields get sensible defaults.
1123
- * Returns undefined if the raw value is absent or not an object,
1124
- * so makeSelfEvaluation can distinguish "not set" from "empty".
1125
- * Exported for the engine's last_round_result rebuild — the MCP
1126
- * lifecycle assembles prompt_requests into the request, but every
1127
- * compile path funnels through engine.invokeLoopCompile, whose
1128
- * field-by-field reconstruction must preserve them (v3.3.1). */
1129
- export function parsePromptRequests(raw, sink) {
1130
- if (raw === undefined)
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
- }
1140
- const obj = raw;
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");
1148
- // Return undefined when all fields are empty — semantically equivalent
1149
- // to "not set", avoids carrying an empty object through the pipeline.
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");
1152
- return undefined;
1153
- }
1154
- return { emphasize, confusion_points: confusionPoints };
1155
- }
1156
1227
  // Free-text inference is intentionally absent. Invalid core fields are
1157
1228
  // rejected before round advancement and may be resubmitted with the same
1158
1229
  // roundId without changing session, gate, metric, or vault state.