loopforge 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
package/dist/self-eval.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Structured self-evaluation parsing and normalization — pure functions.
2
2
  * The MCP boundary validates required fields before these helpers run.
3
3
  */
4
- import { makeExecutionEvidence, makeSelfEvaluation, } from "./protocol.js";
4
+ import { makeExecutionReport, makeSelfEvaluation, } from "./protocol.js";
5
5
  // ── Raw parsing helpers ───────────────────────────────────────────────────
6
6
  function boundedString(value, maxChars) {
7
7
  return typeof value === "string" ? value.slice(0, maxChars) : undefined;
@@ -14,6 +14,19 @@ function boundedStringArray(value, maxItems, maxChars) {
14
14
  .slice(0, maxItems)
15
15
  : [];
16
16
  }
17
+ /** v3.8.1: the emerged-subtask intake bound — ONE constant, because the
18
+ * sub-goal ids are derived by ORDINAL over this list. The strict submission
19
+ * boundary builds the reference space the agent may transition from, and the
20
+ * lenient normalizer builds the committed list every prompt compiles from;
21
+ * bounding them differently let the boundary accept a transition to a
22
+ * sub-goal the prompt could never carry, and the migration was then dropped
23
+ * by the compile path without a word. */
24
+ export const EMERGED_LIMITS = { items: 50, chars: 500 };
25
+ /** THE emerged-list normalization. Both the boundary and the committed
26
+ * evaluation call this, so the two sets cannot disagree. */
27
+ export function boundedEmergedSubtasks(value) {
28
+ return boundedStringArray(value, EMERGED_LIMITS.items, EMERGED_LIMITS.chars);
29
+ }
17
30
  function nonNegativeInteger(value) {
18
31
  return typeof value === "number" && Number.isFinite(value)
19
32
  ? Math.max(0, Math.trunc(value))
@@ -40,17 +53,17 @@ function parseRetroactiveClaims(raw) {
40
53
  }
41
54
  return claims;
42
55
  }
43
- /** Parse ExecutionEvidence from a raw JSON object. */
44
- export function parseExecutionEvidence(raw) {
56
+ /** Parse ExecutionReport from a raw JSON object. */
57
+ export function parseExecutionReport(raw) {
45
58
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
46
59
  return undefined;
47
- const testResults = raw.test_results && typeof raw.test_results === "object" &&
48
- !Array.isArray(raw.test_results)
49
- ? raw.test_results
60
+ const testResults = raw.tests_reported && typeof raw.tests_reported === "object" &&
61
+ !Array.isArray(raw.tests_reported)
62
+ ? raw.tests_reported
50
63
  : undefined;
51
- return makeExecutionEvidence({
64
+ return makeExecutionReport({
52
65
  files_changed: boundedStringArray(raw.files_changed, 200, 500),
53
- test_results: testResults && typeof testResults.passed === "number" &&
66
+ tests_reported: testResults && typeof testResults.passed === "number" &&
54
67
  Number.isFinite(testResults.passed)
55
68
  ? {
56
69
  passed: nonNegativeInteger(testResults.passed),
@@ -58,14 +71,74 @@ export function parseExecutionEvidence(raw) {
58
71
  skipped: nonNegativeInteger(testResults.skipped),
59
72
  }
60
73
  : null,
61
- success_criteria_met: boundedStringArray(raw.success_criteria_met, 100, 500),
62
- success_criteria_remaining: boundedStringArray(raw.success_criteria_remaining, 100, 500),
74
+ criterion_claims: parseCriterionClaims(raw.criterion_claims),
75
+ contract_item_claims: parseContractItemClaims(raw.contract_item_claims),
63
76
  progress_estimate: typeof raw.progress_estimate === "number" &&
64
77
  Number.isFinite(raw.progress_estimate)
65
78
  ? Math.max(0, Math.min(1, raw.progress_estimate))
66
79
  : 0.0,
67
80
  });
68
81
  }
82
+ /** v3.8: The criterion ids the agent claims met this round. */
83
+ export function claimedMetCriteria(report) {
84
+ return (report?.criterion_claims ?? [])
85
+ .filter((claim) => claim.outcome === "met")
86
+ .map((claim) => claim.criterion_id);
87
+ }
88
+ /** v3.8: The criterion ids the agent declares still outstanding. */
89
+ export function claimedRemainingCriteria(report) {
90
+ return (report?.criterion_claims ?? [])
91
+ .filter((claim) => claim.outcome === "remaining")
92
+ .map((claim) => claim.criterion_id);
93
+ }
94
+ /** v3.8: LENIENT parse of advisory criterion claims — unknown or malformed
95
+ * entries are dropped, never a rejection (the contract item layer is the
96
+ * strict one). */
97
+ function parseCriterionClaims(raw) {
98
+ if (!Array.isArray(raw))
99
+ return [];
100
+ const claims = [];
101
+ for (const item of raw) {
102
+ if (claims.length >= 100)
103
+ break;
104
+ if (typeof item !== "object" || item === null || Array.isArray(item))
105
+ continue;
106
+ const entry = item;
107
+ if (typeof entry.criterion_id !== "string" || entry.criterion_id.trim().length === 0)
108
+ continue;
109
+ if (entry.outcome !== "met" && entry.outcome !== "remaining")
110
+ continue;
111
+ claims.push({
112
+ criterion_id: entry.criterion_id.trim().slice(0, 500),
113
+ outcome: entry.outcome,
114
+ });
115
+ }
116
+ return claims;
117
+ }
118
+ /** v3.8: LENIENT parse of contract item claims. Referential validity is the
119
+ * strict pre-advance boundary in validateContractShape — this parser only
120
+ * normalizes what reaches the read model. */
121
+ function parseContractItemClaims(raw) {
122
+ if (!Array.isArray(raw))
123
+ return [];
124
+ const claims = [];
125
+ for (const item of raw) {
126
+ if (claims.length >= 20)
127
+ break;
128
+ if (typeof item !== "object" || item === null || Array.isArray(item))
129
+ continue;
130
+ const entry = item;
131
+ if (typeof entry.item_id !== "string")
132
+ continue;
133
+ if (entry.outcome !== "met" && entry.outcome !== "remaining")
134
+ continue;
135
+ claims.push({
136
+ item_id: entry.item_id.trim().slice(0, 64),
137
+ outcome: entry.outcome,
138
+ });
139
+ }
140
+ return claims;
141
+ }
69
142
  /** Parse CriterionRevision[] from a raw JSON array. */
70
143
  export function parseCriterionRevisions(raw) {
71
144
  if (!Array.isArray(raw))
@@ -80,15 +153,51 @@ export function parseCriterionRevisions(raw) {
80
153
  return { old: r.old.slice(0, 500), new: r.new.slice(0, 500) };
81
154
  });
82
155
  }
83
- /** Parse WorkerResult[] from a raw JSON array. */
84
- export function parseWorkerResults(raw) {
156
+ /** Parse SubGoalUpdate[] from a raw JSON array.
157
+ * v3.7.1: lenient per-entry filtering (id/status shape) with fixed caps
158
+ * (20 entries, id ≤ 64, note ≤ 300). Referential validity — unknown IDs
159
+ * and illegal migrations — is NOT checked here: that needs the committed
160
+ * sub-goal set and runs as a pre-advance validation (evaluation_invalid). */
161
+ export function parseSubGoalUpdates(raw) {
85
162
  if (!Array.isArray(raw))
86
163
  return [];
87
164
  return raw
88
165
  .filter((v) => typeof v === "object" && v !== null &&
89
- typeof v.agentId === "string" &&
90
- typeof v.subTask === "string" &&
91
- typeof v.resultSummary === "string")
166
+ typeof v.id === "string" &&
167
+ (v.status === "in_progress" ||
168
+ v.status === "done" ||
169
+ v.status === "blocked" ||
170
+ v.status === "canceled"))
171
+ .slice(0, 20)
172
+ .map((u) => {
173
+ const update = {
174
+ id: u.id.trim().slice(0, 64),
175
+ status: u.status,
176
+ };
177
+ if (typeof u.note === "string" && u.note.trim().length > 0) {
178
+ update.note = u.note.trim().slice(0, 300);
179
+ }
180
+ return update;
181
+ });
182
+ }
183
+ /** Parse WorkerResult[] from a raw JSON array.
184
+ * v3.7.1: `outcome` is the single fact. An entry without a valid outcome
185
+ * is dropped (not derived, not defaulted) — the array stays informational:
186
+ * absent/empty arrays are accepted unchanged, so a round without any
187
+ * delegation can never be rejected over worker fields. */
188
+ export function parseWorkerResults(raw) {
189
+ if (!Array.isArray(raw))
190
+ return [];
191
+ return raw
192
+ .filter((v) => {
193
+ if (typeof v !== "object" || v === null)
194
+ return false;
195
+ const w = v;
196
+ return typeof w.agentId === "string" &&
197
+ typeof w.subTask === "string" &&
198
+ typeof w.resultSummary === "string" &&
199
+ (w.outcome === "success" || w.outcome === "partial" || w.outcome === "failed");
200
+ })
92
201
  .slice(0, 20)
93
202
  .map((v) => {
94
203
  const w = v;
@@ -99,11 +208,7 @@ export function parseWorkerResults(raw) {
99
208
  : "general-purpose",
100
209
  subTask: w.subTask.slice(0, 500),
101
210
  resultSummary: w.resultSummary.slice(0, 1000),
102
- success: typeof w.success === "boolean" ? w.success : false,
103
- // v2.12: Declared outcome wins; derived from success when absent.
104
- outcome: w.outcome === "success" || w.outcome === "partial" || w.outcome === "failed"
105
- ? w.outcome
106
- : undefined,
211
+ outcome: w.outcome,
107
212
  discoveredConstraints: boundedStringArray(w.discoveredConstraints, 50, 500),
108
213
  };
109
214
  });
@@ -148,10 +253,366 @@ export function validateCoreSelfEvaluation(raw) {
148
253
  }
149
254
  return { missing, invalid };
150
255
  }
256
+ /** v3.7.1: Strict structural checks for the sub-goal protocol fields
257
+ * (subgoal_updates / emerged_subtasks). Runs in the same pre-advance
258
+ * evaluation_invalid boundary as the four core fields: an error here is a
259
+ * payload defect, never a work-quality rejection — no session state, no
260
+ * gates, no rejection counters, retry with the same roundId.
261
+ *
262
+ * Shape-only (no vault): entry shape, id pattern, status enum, caps, and
263
+ * the sg-XXXXXXXX creation prohibition on emerged_subtasks. Referential
264
+ * validity (unknown IDs / terminal references / illegal migrations) needs
265
+ * the committed sub-goal set and is checked against the compiled
266
+ * sub_goals right before advance (validateSubGoalUpdates in
267
+ * loop-compiler.ts). */
268
+ export function validateSubGoalUpdatesShape(raw) {
269
+ const errors = [];
270
+ const emerged = raw.emerged_subtasks;
271
+ if (Array.isArray(emerged)) {
272
+ for (const item of emerged) {
273
+ if (typeof item === "string" && /^sg-[a-f0-9]{8}$/.test(item.trim())) {
274
+ errors.push({
275
+ field: "emerged_subtasks",
276
+ reason: "id_in_creation",
277
+ detail: `"${item.trim()}" matches the sg-XXXXXXXX ID pattern — the creation channel never accepts ID references`,
278
+ });
279
+ }
280
+ }
281
+ }
282
+ const updates = raw.subgoal_updates;
283
+ if (updates === undefined)
284
+ return errors;
285
+ if (!Array.isArray(updates)) {
286
+ errors.push({
287
+ field: "subgoal_updates",
288
+ reason: "not_array",
289
+ detail: "subgoal_updates must be an array of { id, status, note? } entries",
290
+ });
291
+ return errors;
292
+ }
293
+ if (updates.length > 20) {
294
+ errors.push({
295
+ field: "subgoal_updates",
296
+ reason: "too_many",
297
+ detail: "at most 20 subgoal_updates entries per round",
298
+ });
299
+ }
300
+ updates.forEach((entry, index) => {
301
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
302
+ errors.push({
303
+ field: "subgoal_updates",
304
+ reason: "invalid_entry",
305
+ detail: `entry ${index} is not an object`,
306
+ });
307
+ return;
308
+ }
309
+ const u = entry;
310
+ const id = u.id;
311
+ if (typeof id !== "string" || !/^sg-[a-f0-9]{8}$/.test(id.trim())) {
312
+ errors.push({
313
+ field: "subgoal_updates",
314
+ reason: "invalid_id",
315
+ detail: `entry ${index} must reference an sg-XXXXXXXX sub-goal ID`,
316
+ });
317
+ }
318
+ const status = u.status;
319
+ if (status !== "in_progress" && status !== "done" &&
320
+ status !== "blocked" && status !== "canceled") {
321
+ errors.push({
322
+ field: "subgoal_updates",
323
+ reason: "invalid_status",
324
+ detail: `entry ${index} status must be one of in_progress | done | blocked | canceled`,
325
+ });
326
+ }
327
+ // v3.8.1: `note` is deliberately NOT validated. protocol.ts documents it
328
+ // as "lenient — never validated" free text, and `parseSubGoalUpdates`
329
+ // drops a non-string note. Rejecting the WHOLE submission over a
330
+ // reporting annotation contradicted that boundary: it was the only
331
+ // lenient field that hard-rejected a round.
332
+ });
333
+ return errors;
334
+ }
335
+ const SUBGOAL_ID_RE = /^sg-[a-f0-9]{8}$/;
336
+ /** v3.8: `scope` is the round's declared file boundary, so containment is a
337
+ * trust boundary like every other workspace path — a declaration that names
338
+ * `/etc` or `../outside` is rejected, not silently normalized into a string
339
+ * the drift check can never match. */
340
+ function validateContractScope(scope, checkScopeEntry, errors) {
341
+ if (scope === undefined)
342
+ return;
343
+ if (!Array.isArray(scope)) {
344
+ errors.push({
345
+ field: "round_contract",
346
+ reason: "invalid_scope",
347
+ detail: "scope must be an array of workspace-relative paths",
348
+ });
349
+ return;
350
+ }
351
+ if (scope.length > CONTRACT_LIMITS.scope) {
352
+ errors.push({
353
+ field: "round_contract",
354
+ reason: "too_many_scope_entries",
355
+ detail: `scope may list at most ${CONTRACT_LIMITS.scope} entries (got ${scope.length})`,
356
+ });
357
+ }
358
+ scope.forEach((entry, index) => {
359
+ if (typeof entry !== "string" || entry.trim().length === 0) {
360
+ errors.push({
361
+ field: "round_contract",
362
+ reason: "invalid_scope_entry",
363
+ detail: `scope entry ${index} must be a non-empty string`,
364
+ });
365
+ return;
366
+ }
367
+ const detail = checkScopeEntry(entry.trim());
368
+ if (detail) {
369
+ errors.push({
370
+ field: "round_contract",
371
+ reason: "scope_outside_workspace",
372
+ detail: `scope entry ${index} "${entry.trim()}" ${detail}`,
373
+ });
374
+ }
375
+ });
376
+ }
377
+ function validateContractItem(item, index, context, errors) {
378
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
379
+ errors.push({
380
+ field: "round_contract",
381
+ reason: "invalid_item",
382
+ detail: `item ${index} is not an object`,
383
+ });
384
+ return;
385
+ }
386
+ const entry = item;
387
+ if (typeof entry.description !== "string" || entry.description.trim().length === 0) {
388
+ errors.push({
389
+ field: "round_contract",
390
+ reason: "empty_item_description",
391
+ detail: `item ${index} needs a non-empty description`,
392
+ });
393
+ }
394
+ const verifyWith = entry.verify_with;
395
+ if (!Array.isArray(verifyWith) || verifyWith.length === 0) {
396
+ errors.push({
397
+ field: "round_contract",
398
+ reason: "no_verify_with",
399
+ detail: `item ${index} must bind at least one evidence command in verify_with`,
400
+ });
401
+ }
402
+ else {
403
+ if (verifyWith.length > CONTRACT_LIMITS.verifyWith) {
404
+ errors.push({
405
+ field: "round_contract",
406
+ reason: "too_many_verify_with",
407
+ detail: `item ${index} verify_with may name at most ${CONTRACT_LIMITS.verifyWith} commands ` +
408
+ `(got ${verifyWith.length})`,
409
+ });
410
+ }
411
+ for (const name of verifyWith) {
412
+ if (typeof name !== "string" || !context.isConfiguredCommand(name)) {
413
+ errors.push({
414
+ field: "round_contract",
415
+ reason: "unknown_command",
416
+ detail: `item ${index} verify_with names "${String(name)}", which is not a ` +
417
+ "configured, enabled, after-capable evidence command",
418
+ });
419
+ }
420
+ }
421
+ }
422
+ const criterionRefs = entry.criterion_refs;
423
+ if (criterionRefs !== undefined) {
424
+ if (!Array.isArray(criterionRefs)) {
425
+ errors.push({
426
+ field: "round_contract",
427
+ reason: "invalid_criterion_refs",
428
+ detail: `item ${index} criterion_refs must be an array of strings`,
429
+ });
430
+ }
431
+ else {
432
+ if (criterionRefs.length > CONTRACT_LIMITS.criterionRefs) {
433
+ errors.push({
434
+ field: "round_contract",
435
+ reason: "too_many_criterion_refs",
436
+ detail: `item ${index} criterion_refs may list at most ` +
437
+ `${CONTRACT_LIMITS.criterionRefs} ids (got ${criterionRefs.length})`,
438
+ });
439
+ }
440
+ criterionRefs.forEach((ref, refIndex) => {
441
+ if (typeof ref !== "string" || ref.trim().length === 0) {
442
+ errors.push({
443
+ field: "round_contract",
444
+ reason: "invalid_criterion_ref",
445
+ detail: `item ${index} criterion_refs entry ${refIndex} must be a non-empty string`,
446
+ });
447
+ }
448
+ });
449
+ }
450
+ }
451
+ const subgoalRefs = entry.subgoal_refs;
452
+ if (subgoalRefs !== undefined) {
453
+ if (!Array.isArray(subgoalRefs)) {
454
+ errors.push({
455
+ field: "round_contract",
456
+ reason: "invalid_subgoal_refs",
457
+ detail: `item ${index} subgoal_refs must be an array of sg-XXXXXXXX ids`,
458
+ });
459
+ }
460
+ else {
461
+ if (subgoalRefs.length > CONTRACT_LIMITS.subgoalRefs) {
462
+ errors.push({
463
+ field: "round_contract",
464
+ reason: "too_many_subgoal_refs",
465
+ detail: `item ${index} subgoal_refs may list at most ` +
466
+ `${CONTRACT_LIMITS.subgoalRefs} ids (got ${subgoalRefs.length})`,
467
+ });
468
+ }
469
+ const seen = new Set();
470
+ for (const ref of subgoalRefs) {
471
+ if (typeof ref !== "string" || !SUBGOAL_ID_RE.test(ref.trim())) {
472
+ errors.push({
473
+ field: "round_contract",
474
+ reason: "invalid_subgoal_ref",
475
+ detail: `item ${index} subgoal_refs entry "${String(ref)}" must be an sg-XXXXXXXX id`,
476
+ });
477
+ continue;
478
+ }
479
+ const id = ref.trim();
480
+ if (seen.has(id)) {
481
+ errors.push({
482
+ field: "round_contract",
483
+ reason: "duplicate_subgoal_ref",
484
+ detail: `item ${index} subgoal_refs repeats ${id}`,
485
+ });
486
+ }
487
+ seen.add(id);
488
+ if (context.knownSubGoalIds && !context.knownSubGoalIds.has(id)) {
489
+ errors.push({
490
+ field: "round_contract",
491
+ reason: "unknown_subgoal_ref",
492
+ detail: `item ${index} references ${id}, which is not a sub-goal of this loop`,
493
+ });
494
+ }
495
+ }
496
+ }
497
+ }
498
+ }
499
+ /** v3.8: Strict STRUCTURAL boundary for the Round Contract declaration and
500
+ * the contract item claims — the same pre-advance contract as
501
+ * subgoal_updates: an error here is a payload defect, never a work-quality
502
+ * rejection (no session state, no gates, no rejection counters, retry with
503
+ * the same roundId as `contract_invalid`).
504
+ *
505
+ * Declaration strictness: items must exist and stay within CONTRACT_LIMITS;
506
+ * every item must bind at least one configured, enabled, after-capable
507
+ * command; scope entries must stay inside the workspace; criterion and
508
+ * sub-goal references must be well-formed, and sub-goal references must name
509
+ * a sub-goal that actually exists. The limits are enforced HERE rather than
510
+ * by silently truncating in the lenient parser — a declaration is a
511
+ * boundary, not a suggestion.
512
+ *
513
+ * Claim strictness: item ids must be well formed, unique, and — when the
514
+ * active contract is known — reference an item of the ACTIVE contract. */
515
+ export function validateContractShape(raw, context) {
516
+ const errors = [];
517
+ const contract = raw.round_contract;
518
+ if (contract !== undefined) {
519
+ if (typeof contract !== "object" || contract === null || Array.isArray(contract)) {
520
+ errors.push({
521
+ field: "round_contract",
522
+ reason: "not_object",
523
+ detail: "round_contract must be an object with an items array",
524
+ });
525
+ }
526
+ else {
527
+ const obj = contract;
528
+ validateContractScope(obj.scope, context.checkScopeEntry, errors);
529
+ const items = obj.items;
530
+ if (!Array.isArray(items) || items.length === 0) {
531
+ errors.push({
532
+ field: "round_contract",
533
+ reason: "no_items",
534
+ detail: "a contract must declare at least one item (description + verify_with)",
535
+ });
536
+ }
537
+ else if (items.length > CONTRACT_LIMITS.items) {
538
+ errors.push({
539
+ field: "round_contract",
540
+ reason: "too_many_items",
541
+ detail: `a contract may declare at most ${CONTRACT_LIMITS.items} items (got ${items.length})`,
542
+ });
543
+ }
544
+ else {
545
+ items.forEach((item, index) => {
546
+ validateContractItem(item, index, context, errors);
547
+ });
548
+ }
549
+ }
550
+ }
551
+ const report = raw.execution_report;
552
+ if (typeof report === "object" && report !== null && !Array.isArray(report)) {
553
+ const claims = report.contract_item_claims;
554
+ if (claims !== undefined) {
555
+ if (!Array.isArray(claims)) {
556
+ errors.push({
557
+ field: "execution_report.contract_item_claims",
558
+ reason: "not_array",
559
+ detail: "contract_item_claims must be an array of { item_id, outcome } entries",
560
+ });
561
+ }
562
+ else {
563
+ const seen = new Set();
564
+ claims.forEach((claim, index) => {
565
+ if (typeof claim !== "object" || claim === null || Array.isArray(claim)) {
566
+ errors.push({
567
+ field: "execution_report.contract_item_claims",
568
+ reason: "invalid_entry",
569
+ detail: `entry ${index} is not an object`,
570
+ });
571
+ return;
572
+ }
573
+ const entry = claim;
574
+ const id = typeof entry.item_id === "string" ? entry.item_id.trim() : "";
575
+ if (!/^rci-[a-f0-9]{8}$/.test(id)) {
576
+ errors.push({
577
+ field: "execution_report.contract_item_claims",
578
+ reason: "invalid_item_id",
579
+ detail: `entry ${index} must reference an rci-XXXXXXXX contract item id`,
580
+ });
581
+ }
582
+ else {
583
+ if (seen.has(id)) {
584
+ errors.push({
585
+ field: "execution_report.contract_item_claims",
586
+ reason: "duplicate_item_id",
587
+ detail: `entry ${index} repeats item id ${id}`,
588
+ });
589
+ }
590
+ seen.add(id);
591
+ if (context.activeItemIds && !context.activeItemIds.has(id)) {
592
+ errors.push({
593
+ field: "execution_report.contract_item_claims",
594
+ reason: "unknown_item_id",
595
+ detail: `entry ${index} references ${id}, which is not an item of the ACTIVE contract`,
596
+ });
597
+ }
598
+ }
599
+ if (entry.outcome !== "met" && entry.outcome !== "remaining") {
600
+ errors.push({
601
+ field: "execution_report.contract_item_claims",
602
+ reason: "invalid_outcome",
603
+ detail: `entry ${index} outcome must be "met" or "remaining"`,
604
+ });
605
+ }
606
+ });
607
+ }
608
+ }
609
+ }
610
+ return errors;
611
+ }
151
612
  /** Build a SelfEvaluation from a parsed JSON object.
152
613
  * Lenient parsing: missing optional fields get sensible defaults. */
153
614
  export function buildSelfEvaluation(raw) {
154
- const executionEvidence = parseExecutionEvidence(raw.execution_evidence);
615
+ const executionReport = parseExecutionReport(raw.execution_report);
155
616
  const retractedConstraints = boundedStringArray(raw.retracted_constraints, 50, 500);
156
617
  const revisedCriteria = parseCriterionRevisions(raw.revised_success_criteria);
157
618
  const wrongAssumptions = boundedStringArray(raw.wrong_assumptions, 50, 500);
@@ -165,18 +626,15 @@ export function buildSelfEvaluation(raw) {
165
626
  should_continue: typeof raw.should_continue === "boolean" ? raw.should_continue : true,
166
627
  discovered_constraints: boundedStringArray(raw.discovered_constraints, 50, 500),
167
628
  objective_refinement: boundedString(raw.objective_refinement, 1000) ?? "",
168
- emerged_subtasks: boundedStringArray(raw.emerged_subtasks, 50, 500),
169
- execution_evidence: executionEvidence,
629
+ emerged_subtasks: boundedEmergedSubtasks(raw.emerged_subtasks),
630
+ execution_report: executionReport,
170
631
  retracted_constraints: retractedConstraints,
171
632
  revised_success_criteria: revisedCriteria,
172
633
  wrong_assumptions: wrongAssumptions,
173
634
  worker_results: workerResults,
174
635
  compression_checkpoint: typeof raw.compression_checkpoint === "boolean" ? raw.compression_checkpoint : false,
175
636
  checkpoint_label: boundedString(raw.checkpoint_label, 200) ?? "",
176
- next_action: boundedString(raw.next_action, 500),
177
- completed_subtasks: boundedStringArray(raw.completed_subtasks, 50, 500),
178
- blocked_subtasks: boundedStringArray(raw.blocked_subtasks, 50, 500),
179
- canceled_subtasks: boundedStringArray(raw.canceled_subtasks, 50, 500),
637
+ subgoal_updates: parseSubGoalUpdates(raw.subgoal_updates),
180
638
  stop_reason: raw.stop_reason === "gave_up" || raw.stop_reason === "blocked" || raw.stop_reason === "needs_human_input"
181
639
  ? raw.stop_reason
182
640
  : undefined,
@@ -187,20 +645,31 @@ export function buildSelfEvaluation(raw) {
187
645
  blocker: typeof raw.blocker === "string" && raw.blocker.trim().length > 0
188
646
  ? raw.blocker.slice(0, 500)
189
647
  : undefined,
648
+ gate_ids: boundedStringArray(raw.gate_ids, 20, 64),
190
649
  retroactiveClaims: parseRetroactiveClaims(raw.retroactiveClaims),
191
650
  no_change_reason: typeof raw.no_change_reason === "string" && raw.no_change_reason.trim().length > 0
192
651
  ? raw.no_change_reason.slice(0, 200)
193
652
  : undefined,
194
- drift_clarification: boundedString(raw.drift_clarification, 1000),
195
653
  prompt_requests: parsePromptRequests(raw.prompt_requests),
196
654
  round_contract: parseRoundContract(raw.round_contract),
197
655
  });
198
656
  }
199
- /** Parse a RoundContract object from raw JSON input. Lenient: non-string
200
- * entries are dropped, strings are trimmed and capped. An object that IS
201
- * present is returned even with empty arrays — an empty contract is a
202
- * real declaration the round_underspecified check must see. Returns
203
- * undefined only when the raw value is absent or not an object. */
657
+ /** v3.8: The ONE set of contract-declaration limits. `validateContractShape`
658
+ * (the strict boundary) rejects a declaration over these; `parseRoundContract`
659
+ * (the lenient path, reached only after the strict boundary passed) truncates
660
+ * to the same numbers. One source, so the two can never drift. */
661
+ export const CONTRACT_LIMITS = {
662
+ items: 20,
663
+ scope: 50,
664
+ criterionRefs: 20,
665
+ subgoalRefs: 20,
666
+ verifyWith: 20,
667
+ };
668
+ /** Parse a Round Contract PROPOSAL from raw JSON input. Lenient on shapes
669
+ * the strict declaration boundary already rejected: strings are trimmed and
670
+ * capped, non-string array entries are dropped. Item ORDER and COUNT are
671
+ * preserved — the derived rci- ids depend on them. Returns undefined only
672
+ * when the raw value is absent or not an object. */
204
673
  export function parseRoundContract(raw) {
205
674
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
206
675
  return undefined;
@@ -212,13 +681,25 @@ export function parseRoundContract(raw) {
212
681
  .filter(Boolean)
213
682
  .slice(0, cap)
214
683
  : [];
684
+ const items = Array.isArray(obj.items)
685
+ ? obj.items
686
+ .slice(0, CONTRACT_LIMITS.items)
687
+ .filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
688
+ .map((item) => ({
689
+ description: typeof item.description === "string"
690
+ ? item.description.trim().slice(0, 200)
691
+ : "",
692
+ criterion_refs: capStrings(item.criterion_refs, 200, CONTRACT_LIMITS.criterionRefs),
693
+ subgoal_refs: capStrings(item.subgoal_refs, 64, CONTRACT_LIMITS.subgoalRefs),
694
+ verify_with: capStrings(item.verify_with, 200, CONTRACT_LIMITS.verifyWith),
695
+ }))
696
+ : [];
215
697
  return {
216
698
  work_item: typeof obj.work_item === "string" && obj.work_item.trim().length > 0
217
699
  ? obj.work_item.trim().slice(0, 200)
218
700
  : undefined,
219
- done_when: capStrings(obj.done_when, 200, 20),
220
- verification_plan: capStrings(obj.verification_plan, 200, 20),
221
- scope: capStrings(obj.scope, 500, 50),
701
+ scope: capStrings(obj.scope, 500, CONTRACT_LIMITS.scope),
702
+ items,
222
703
  };
223
704
  }
224
705
  /** Parse a PromptRequests object from raw JSON input.