loopforge 3.7.1 → 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 (144) hide show
  1. package/README.md +171 -169
  2. package/dist/audit.d.ts +53 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +106 -22
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +52 -67
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +134 -147
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +103 -4
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +13 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -1
  15. package/dist/cognitive-facts.js +28 -13
  16. package/dist/cognitive-facts.js.map +1 -1
  17. package/dist/committed-round.d.ts +103 -21
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +188 -45
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/contract-items.d.ts +55 -0
  22. package/dist/contract-items.d.ts.map +1 -0
  23. package/dist/contract-items.js +180 -0
  24. package/dist/contract-items.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +20 -2
  26. package/dist/enforcement-gate.d.ts.map +1 -1
  27. package/dist/enforcement-gate.js +197 -386
  28. package/dist/enforcement-gate.js.map +1 -1
  29. package/dist/engine.d.ts +1 -1
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +53 -38
  32. package/dist/engine.js.map +1 -1
  33. package/dist/evidence-claims.d.ts +4 -4
  34. package/dist/evidence-claims.d.ts.map +1 -1
  35. package/dist/evidence-claims.js +16 -14
  36. package/dist/evidence-claims.js.map +1 -1
  37. package/dist/evidence-provider.d.ts +85 -89
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +328 -184
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/explain.d.ts +76 -0
  42. package/dist/explain.d.ts.map +1 -0
  43. package/dist/explain.js +127 -0
  44. package/dist/explain.js.map +1 -0
  45. package/dist/generate-schema.js +152 -16
  46. package/dist/generate-schema.js.map +1 -1
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -5
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop-compiler.d.ts +40 -53
  52. package/dist/loop-compiler.d.ts.map +1 -1
  53. package/dist/loop-compiler.js +229 -553
  54. package/dist/loop-compiler.js.map +1 -1
  55. package/dist/loop-extras-parser.d.ts +0 -1
  56. package/dist/loop-extras-parser.d.ts.map +1 -1
  57. package/dist/loop-extras-parser.js +0 -4
  58. package/dist/loop-extras-parser.js.map +1 -1
  59. package/dist/loop-projection.d.ts.map +1 -1
  60. package/dist/loop-projection.js +3 -1
  61. package/dist/loop-projection.js.map +1 -1
  62. package/dist/mcp/round-lifecycle.d.ts +30 -21
  63. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  64. package/dist/mcp/round-lifecycle.js +47 -103
  65. package/dist/mcp/round-lifecycle.js.map +1 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +51 -7
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/mcp/session.d.ts +39 -23
  70. package/dist/mcp/session.d.ts.map +1 -1
  71. package/dist/mcp/session.js +148 -163
  72. package/dist/mcp/session.js.map +1 -1
  73. package/dist/mcp/submission-boundary.d.ts +89 -0
  74. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  75. package/dist/mcp/submission-boundary.js +203 -0
  76. package/dist/mcp/submission-boundary.js.map +1 -0
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +249 -128
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/policy-metrics.js +2 -2
  81. package/dist/policy-metrics.js.map +1 -1
  82. package/dist/policy.d.ts +55 -93
  83. package/dist/policy.d.ts.map +1 -1
  84. package/dist/policy.js +149 -38
  85. package/dist/policy.js.map +1 -1
  86. package/dist/prompt-assembler.d.ts +1 -32
  87. package/dist/prompt-assembler.d.ts.map +1 -1
  88. package/dist/prompt-assembler.js +282 -435
  89. package/dist/prompt-assembler.js.map +1 -1
  90. package/dist/prompt-policy.d.ts +1 -4
  91. package/dist/prompt-policy.d.ts.map +1 -1
  92. package/dist/prompt-policy.js +6 -7
  93. package/dist/prompt-policy.js.map +1 -1
  94. package/dist/protocol.d.ts +365 -142
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/dist/protocol.js +21 -66
  97. package/dist/protocol.js.map +1 -1
  98. package/dist/replay.js +2 -2
  99. package/dist/replay.js.map +1 -1
  100. package/dist/round-contract.d.ts +69 -57
  101. package/dist/round-contract.d.ts.map +1 -1
  102. package/dist/round-contract.js +104 -64
  103. package/dist/round-contract.js.map +1 -1
  104. package/dist/round-coordinator.d.ts +36 -16
  105. package/dist/round-coordinator.d.ts.map +1 -1
  106. package/dist/round-coordinator.js +166 -102
  107. package/dist/round-coordinator.js.map +1 -1
  108. package/dist/round-driver.d.ts +3 -5
  109. package/dist/round-driver.d.ts.map +1 -1
  110. package/dist/round-driver.js +0 -1
  111. package/dist/round-driver.js.map +1 -1
  112. package/dist/round-facts.d.ts +96 -0
  113. package/dist/round-facts.d.ts.map +1 -0
  114. package/dist/round-facts.js +216 -0
  115. package/dist/round-facts.js.map +1 -0
  116. package/dist/round-transaction.d.ts +19 -9
  117. package/dist/round-transaction.d.ts.map +1 -1
  118. package/dist/round-transaction.js +90 -22
  119. package/dist/round-transaction.js.map +1 -1
  120. package/dist/self-eval.d.ts +87 -9
  121. package/dist/self-eval.d.ts.map +1 -1
  122. package/dist/self-eval.js +396 -27
  123. package/dist/self-eval.js.map +1 -1
  124. package/dist/subgoal-state.d.ts +77 -0
  125. package/dist/subgoal-state.d.ts.map +1 -0
  126. package/dist/subgoal-state.js +198 -0
  127. package/dist/subgoal-state.js.map +1 -0
  128. package/dist/token-utils.d.ts +52 -19
  129. package/dist/token-utils.d.ts.map +1 -1
  130. package/dist/token-utils.js +83 -72
  131. package/dist/token-utils.js.map +1 -1
  132. package/dist/verification-gate.d.ts +14 -31
  133. package/dist/verification-gate.d.ts.map +1 -1
  134. package/dist/verification-gate.js +178 -496
  135. package/dist/verification-gate.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/workspace.d.ts +9 -0
  139. package/dist/workspace.d.ts.map +1 -1
  140. package/dist/workspace.js +17 -0
  141. package/dist/workspace.js.map +1 -1
  142. package/loop_policy.json +6 -30
  143. package/package.json +1 -1
  144. package/skills/perception/SKILL.md +243 -79
@@ -17,11 +17,10 @@
17
17
  * with stopReason "enforcement_terminated".
18
18
  */
19
19
  import { makeEnforcementResult } from "./protocol.js";
20
- import { machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_PREMATURE_BOUNDARY, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_COMPLETION_UNVERIFIED, CHECK_USER_GATE_UNRESOLVED } from "./verification-gate.js";
21
- import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
20
+ import { machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_ITEMS_UNVERIFIED, CHECK_USER_GATE_UNRESOLVED } from "./verification-gate.js";
22
21
  import { getPolicy } from "./policy.js";
23
- import { STABLE_ID_RE, isRecord, entryRound, extractFilePathTokens } from "./token-utils.js";
24
- import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
22
+ import { isRecord, entryRound } from "./token-utils.js";
23
+ import { derivationRounds, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
25
24
  // ═══════════════════════════════════════════════════════════════════════════
26
25
  // Helpers
27
26
  // ═══════════════════════════════════════════════════════════════════════════
@@ -29,8 +28,8 @@ import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRou
29
28
  * Shared by the progress evaluator's stall and flatline tiers. */
30
29
  function collectProgressByRound(vaultEntries, currentRound) {
31
30
  const progressByRound = new Map();
32
- for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
33
- const progress = round.executionEvidence?.progress_estimate;
31
+ for (const round of derivationRounds(vaultEntries, currentRound)) {
32
+ const progress = round.executionReport?.progress_estimate;
34
33
  if (typeof progress === "number")
35
34
  progressByRound.set(round.round, progress);
36
35
  }
@@ -46,7 +45,7 @@ function collectProgressByRound(vaultEntries, currentRound) {
46
45
  function progressWindow(vaultEntries, currentRound, selfEval) {
47
46
  const progressByRound = collectProgressByRound(vaultEntries, currentRound);
48
47
  if (hasCommittedBacktrack(vaultEntries, currentRound)) {
49
- const pe = selfEval.execution_evidence?.progress_estimate;
48
+ const pe = selfEval.execution_report?.progress_estimate;
50
49
  if (typeof pe === "number")
51
50
  progressByRound.set(currentRound, pe);
52
51
  }
@@ -89,7 +88,7 @@ function isCleanRound(round) {
89
88
  * Collects discovered_constraints from skipped rounds along the way.
90
89
  * Returns null when no clean round is found within maxDepth. */
91
90
  export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
92
- const completed = committedRoundsFromEntries(vaultEntries, currentRound)
91
+ const completed = derivationRounds(vaultEntries, currentRound)
93
92
  .sort((a, b) => b.round - a.round);
94
93
  const skippedDiscoveries = [];
95
94
  for (let depth = 1; depth <= maxDepth; depth++) {
@@ -124,13 +123,13 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
124
123
  * evidence as fallback). Returns null when the round has no git snapshot
125
124
  * or no committed feedback entry. */
126
125
  export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
127
- const committed = committedRoundsFromEntries(vaultEntries)
126
+ const committed = derivationRounds(vaultEntries)
128
127
  .find((view) => view.round === restoreRound);
129
128
  if (!committed)
130
129
  return null;
131
130
  const evidence = machineEvidenceForRound(committed);
132
131
  for (const item of evidence) {
133
- if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
132
+ if (!isRecord(item) || item.providerId !== "git" || !isRecord(item.data))
134
133
  continue;
135
134
  const head = item.data.head;
136
135
  if (typeof head === "string" && head.length > 0)
@@ -138,6 +137,44 @@ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
138
137
  }
139
138
  return null;
140
139
  }
140
+ /** v3.8.1: the ONE Recovery Brief renderer.
141
+ *
142
+ * The rollback prompt and the state file's Recent tier used to carry two
143
+ * hand-written copies of these lines — differing in punctuation
144
+ * ("the redo is round N" vs "redo round N") and in whether an approach
145
+ * overflow count was shown at all. Two renderers over one rollback meant the
146
+ * two surfaces could describe the same event differently while both claiming
147
+ * to be derived facts. The facts are supplied by the caller: the coordinator
148
+ * from its live walk, the compile path from `decodeBacktrackRecord` on the
149
+ * committed decision. */
150
+ export function recoveryBriefLines(input) {
151
+ const maxApproaches = input.maxApproaches ?? 6;
152
+ const maxAssumptions = input.maxAssumptions ?? 5;
153
+ const lines = [
154
+ `- **Trigger**: ${input.triggerRule || "unknown"}`,
155
+ `- **Restored to round**: ${input.target} (the redo is round ${input.target + 1})`,
156
+ ];
157
+ if (input.recoveryRoundId) {
158
+ lines.push(`- **Recovery Round ID**: \`${input.recoveryRoundId}\``);
159
+ }
160
+ if (input.failedRounds.length > 0) {
161
+ lines.push(`- **Failed rounds**: ${input.failedRounds.join(", ")}`);
162
+ }
163
+ const shown = input.approaches.slice(0, maxApproaches);
164
+ for (const approach of shown) {
165
+ lines.push(` - Failed approach: ${approach}`);
166
+ }
167
+ if (input.approaches.length > shown.length) {
168
+ lines.push(` - … and ${input.approaches.length - shown.length} more failed approaches`);
169
+ }
170
+ if (input.wrongAssumptions.length > 0) {
171
+ lines.push("- **Falsified assumptions** (do not rebuild on these):");
172
+ for (const assumption of input.wrongAssumptions.slice(0, maxAssumptions)) {
173
+ lines.push(` - ${assumption}`);
174
+ }
175
+ }
176
+ return lines;
177
+ }
141
178
  export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
142
179
  /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
143
180
  * Used to show the agent exactly what needs to be reverted. */
@@ -161,27 +198,14 @@ recovery) {
161
198
  // why it was rolled back, what failed, and what the redo round is.
162
199
  if (recovery) {
163
200
  lines.push("### Recovery Brief", "");
164
- lines.push(`- **Trigger**: ${triggerRule}`);
165
- lines.push(`- **Restored to round**: ${toRound} (the redo is round ${toRound + 1})`);
166
- if (recoveryRoundId) {
167
- lines.push(`- **Recovery Round ID**: \`${recoveryRoundId}\``);
168
- }
169
- if (recovery.failedRounds.length > 0) {
170
- lines.push(`- **Failed rounds**: ${recovery.failedRounds.join(", ")}`);
171
- const shown = recovery.approaches.slice(0, 6);
172
- for (const approach of shown) {
173
- lines.push(` - Failed approach: ${approach}`);
174
- }
175
- if (recovery.approaches.length > shown.length) {
176
- lines.push(` - … and ${recovery.approaches.length - shown.length} more failed approaches`);
177
- }
178
- }
179
- if (recovery.wrongAssumptions.length > 0) {
180
- lines.push("- **Falsified assumptions** (do not rebuild on these):");
181
- for (const assumption of recovery.wrongAssumptions.slice(0, 5)) {
182
- lines.push(` - ${assumption}`);
183
- }
184
- }
201
+ lines.push(...recoveryBriefLines({
202
+ target: toRound,
203
+ triggerRule,
204
+ failedRounds: recovery.failedRounds,
205
+ approaches: recovery.approaches,
206
+ wrongAssumptions: recovery.wrongAssumptions,
207
+ recoveryRoundId,
208
+ }));
185
209
  lines.push("");
186
210
  }
187
211
  // ── Why This Happened ────────────────────────────────────────────────
@@ -210,18 +234,27 @@ recovery) {
210
234
  }
211
235
  lines.push("");
212
236
  }
213
- lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Keep everything as a safety net (recommended) — reversible", `git stash push -u -m "backtrack-safety-net-round-${fromRound}"`, "", "# Option B: Discard ALL uncommitted AND untracked changes (permanent)", "git checkout -- .", "git clean -fd", "```", "", "> Option A is a safety net — do NOT `git stash pop` before submitting " +
214
- "the next round: the skipped work would look unrestored and be rejected. " +
215
- "Restore only files you own after the next round commits, via " +
216
- "`git checkout stash@{0} -- <path>`.", "");
217
- // v2.12: Restore to the exact commit observed at the clean round.
237
+ // v3.8: FACTS, not commands. LoopForge never mutates the working tree, and it
238
+ // does not prescribe how the agent restores it either — the restore is the
239
+ // AGENT's action, so the prompt states what must be true (the target HEAD and
240
+ // the files that must be reverted) and leaves the means entirely to the
241
+ // agent. No `git stash` / `git reset` / `git checkout` / `git clean` line
242
+ // belongs here.
218
243
  if (gitHead) {
219
- lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
220
- "gate checks that your working tree returns to this commit before " +
221
- "accepting your next submission.", "", "```bash", "# Option C: If the failed rounds created commits, reset HEAD to the restore point", `git reset --hard ${gitHead.slice(0, 12)}`, "```", "", "> Option C discards the failed rounds' commits — use it only when those", "> commits belong to the failed work.", "");
244
+ lines.push("**The workspace must be returned to the restore point before you " +
245
+ "continue:**", "", `- HEAD must be at \`${gitHead.slice(0, 12)}\``, `- Every file listed above must be back in its state at Round ${toRound}`, `- No leftover change from rounds ${toRound + 1}–${fromRound} may remain`, "", "Your next submission's git observation is compared against that HEAD, " +
246
+ "and a file whose fingerprint still matches the failed round proves the " +
247
+ "restore did not happen.", "");
222
248
  }
223
- lines.push(`**If git is unavailable**, manually revert the files listed above ` +
224
- `to their state at Round ${toRound}. Re-read the state file below — ` +
249
+ else {
250
+ lines.push(`**The workspace must be returned to its state at Round ${toRound}** — ` +
251
+ "every file listed above reverted, with nothing left over from the " +
252
+ "failed rounds.", "");
253
+ }
254
+ lines.push("Restoring the workspace is **your** responsibility. LoopForge never " +
255
+ "modifies the working tree; it only records the restore requirement and " +
256
+ "checks the result.", "", `**If git is unavailable**, revert the files listed above by hand to ` +
257
+ `their state at Round ${toRound}. Re-read the state file below — ` +
225
258
  `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
226
259
  "> your workspace no longer contains the failed changes. Submissions " +
227
260
  "> with unrestored files will be **rejected**.", "");
@@ -256,7 +289,7 @@ recovery) {
256
289
  // Individual enforcement rules — each returns EnforcementResult | null
257
290
  // Rules are ordered by priority. The first non-null result wins.
258
291
  // ═══════════════════════════════════════════════════════════════════════════
259
- /** R1: Agent claims success but success_criteria_remaining has items.
292
+ /** R1: Agent claims success but declared criteria remain outstanding.
260
293
  * This is a lie — the agent must either finish the criteria or
261
294
  * set success=false honestly. Triggered by the verification gate's
262
295
  * "success_with_remaining_criteria" error flag. */
@@ -363,118 +396,91 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
363
396
  "test evidence and no test results were observed. Success must be backed " +
364
397
  "by machine-verifiable evidence.",
365
398
  fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
366
- "report results in execution_evidence.test_results, or (b) configure a " +
399
+ "report results in execution_report.tests_reported, or (b) configure a " +
367
400
  "required verification command and run it, or (c) if no change was " +
368
401
  "genuinely needed, declare no_change_reason explaining why." + escalation,
369
402
  check: "success_without_verified_evidence",
370
403
  });
371
404
  }
372
- /** R-C1 (v3.3): Round Contract boundary claimed prematurely. The gate
373
- * produced premature_boundary (error): success claimed while done_when
374
- * items were claimed met without machine-verified evidence or silently
375
- * dropped. First occurrence → reject with contract-specific fix
376
- * instructions; second consecutive → terminate (R8 ladder). */
377
- function enforcePrematureBoundary(flags, consecutiveRejections) {
378
- const flag = flags.find((f) => f.check === CHECK_PREMATURE_BOUNDARY && f.severity === "error");
379
- if (!flag)
405
+ /** v3.8: Verification debt — contract items claimed met without machine
406
+ * verification. The round COMMITS (insufficient is recorded, not rejected);
407
+ * this row only fires once the debt has persisted for
408
+ * `engine.unverified_claim_streak_limit` consecutive rounds, then runs its
409
+ * OWN ladder: reject → terminate as `incomplete`.
410
+ *
411
+ * v3.8.1: the ladder lives here (RULE_TABLE marks the row "internal").
412
+ * Declaring it "uniform" as well ran the escalation twice — the notice was
413
+ * appended twice to one fix_instructions — and made the uniform terminate
414
+ * branch unreachable, while its bare `consecutiveRejections` read let a
415
+ * streak earned by a DIFFERENT check terminate this row on its FIRST
416
+ * occurrence, which is exactly what the L4 rule forbids.
417
+ *
418
+ * The streak is derived from COMMITTED round flags, so it survives restart
419
+ * and cannot be moved by an agent self-report. Git motion does not excuse
420
+ * it: churning code without verifying is exactly what this row catches. */
421
+ function enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
422
+ const limit = getPolicy().engine.unverified_claim_streak_limit;
423
+ if (!Number.isFinite(limit) || limit <= 0)
380
424
  return null;
381
- if (consecutiveRejections >= 2) {
382
- return makeEnforcementResult({
383
- action: "terminate",
384
- reason: "Repeated premature Round Contract boundary claims — done_when items " +
385
- "claimed without machine evidence or dropped while success is claimed.",
386
- check: "premature_boundary",
387
- });
425
+ const current = flags.find((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
426
+ if (!current)
427
+ return null;
428
+ let streak = 1;
429
+ for (let round = currentRound - 1; round >= 1; round--) {
430
+ const entry = vaultEntries.find((item) => entryRound(item) === round && String(item.task_id ?? "").endsWith(":feedback"));
431
+ const committed = entry ? decodeCommittedRound(entry) : null;
432
+ const carried = committed?.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
433
+ if (!carried)
434
+ break;
435
+ streak++;
388
436
  }
389
- const escalation = getPolicy().engine.enforcement_escalation_enabled &&
390
- consecutiveRejections >= 1
391
- ? buildEscalationNotice()
392
- : "";
393
- return makeEnforcementResult({
394
- action: "reject",
395
- reason: flag.detail,
396
- fix_instructions: "You claimed success under a Round Contract but done_when items are " +
397
- "unfinished or unverified. You must: (a) run the contract's " +
398
- "verification_plan commands (configured in loop_policy.json " +
399
- "evidence.commands) and report their actual output, listing satisfied " +
400
- "done_when items in success_criteria_met; or (b) set success=false and " +
401
- "list what remains in success_criteria_remaining. " +
402
- "Do NOT claim contract completion without machine evidence " +
403
- "(no_change_reason does not apply to contract claims)." + escalation,
404
- check: "premature_boundary",
405
- });
406
- }
407
- /** v3.5: Round Contract completion claimed without its verification_plan
408
- * commands passing this round. The gate produced
409
- * contract_completion_unverified (error): the eval's met claims satisfy
410
- * every done_when of the ACTIVE contract, but the machine never observed
411
- * the plan's commands pass. Closing a contract is a success-class claim —
412
- * first occurrence → reject with fix instructions; second consecutive →
413
- * terminate (R-C1's ladder). Registered BEFORE R-C1: the completion-truth
414
- * question outranks boundary nuance. */
415
- function enforceContractCompletionUnverified(flags, consecutiveRejections) {
416
- const flag = flags.find((f) => f.check === CHECK_CONTRACT_COMPLETION_UNVERIFIED && f.severity === "error");
417
- if (!flag)
437
+ if (streak < limit)
418
438
  return null;
419
- if (consecutiveRejections >= 2) {
439
+ // The counter measures the PREVIOUS round's check (the coordinator resets it
440
+ // whenever the check changes), so it only speaks for this row when it names
441
+ // this row — same rule the uniform rows apply.
442
+ const ownRejections = lastRejectionCheck === "contract_items_unverified"
443
+ ? consecutiveRejections
444
+ : 0;
445
+ if (ownRejections >= 2) {
420
446
  return makeEnforcementResult({
421
447
  action: "terminate",
422
- reason: "Repeated unverified Round Contract completion claims — done_when items " +
423
- "claimed complete while the verification_plan commands never passed.",
424
- check: "contract_completion_unverified",
448
+ reason: `Verification debt persisted for ${streak} rounds — contract items ` +
449
+ `claimed met while no bound command was observed passing.`,
450
+ check: "contract_items_unverified",
451
+ stopReason: "incomplete",
425
452
  });
426
453
  }
427
454
  const escalation = getPolicy().engine.enforcement_escalation_enabled &&
428
- consecutiveRejections >= 1
455
+ ownRejections >= 1
429
456
  ? buildEscalationNotice()
430
457
  : "";
431
458
  return makeEnforcementResult({
432
459
  action: "reject",
433
- reason: flag.detail,
434
- fix_instructions: "You claimed completion under the ACTIVE Round Contract, but its " +
435
- "verification_plan commands did not pass this round. You must: (a) fix " +
436
- "the underlying failure, re-run the contract's verification_plan " +
437
- "commands (configured in loop_policy.json evidence.commands), and list " +
438
- "satisfied done_when items in success_criteria_met; or (b) set " +
439
- "success=false and list what remains in success_criteria_remaining. " +
440
- "Completion claims are never accepted without machine verification — " +
441
- "do NOT use no_change_reason for completion claims." + escalation,
442
- check: "contract_completion_unverified",
460
+ reason: current.detail,
461
+ fix_instructions: `Contract items have been claimed met for ${streak} consecutive rounds ` +
462
+ "without machine verification. You must either: (a) run the item's bound " +
463
+ "command(s) so they are observed passing, or (b) report the item as " +
464
+ "remaining / declare outcome: \"blocked\" with a blocker. " +
465
+ "An unverified claim is never accepted as completion." + escalation,
466
+ check: "contract_items_unverified",
443
467
  });
444
468
  }
445
469
  /** R-C2 (v3.3): Round Contract scope drift. The gate produced
446
470
  * round_scope_drift (warn): files changed outside the contract's declared
447
- * scope. A substantive drift_clarification (≥ 20 chars with real anchors —
448
- * the out-of-scope file paths count) accepts the legitimately expanded
449
- * scope; no/weak clarification rejects; repeated unclarified drift
450
- * terminates. Mirrors R8's ladder, not R7's streak counter — no new
451
- * session state. */
452
- function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries = []) {
471
+ * scope. v3.8: scope drift is a MACHINE fact and is no longer waivable by an
472
+ * agent explanation (the drift_clarification channel was deleted) — the
473
+ * agent must revert the out-of-scope changes or close the active contract
474
+ * and declare an extended scope. Repeated drift terminates. */
475
+ function enforceScopeDrift(flags, consecutiveRejections) {
453
476
  const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
454
477
  if (!flag)
455
478
  return null;
456
- const clarification = selfEval.drift_clarification?.trim();
457
- const hasAnchor = clarification
458
- ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
459
- : false;
460
- const isSubstantive = clarification !== undefined
461
- && clarification.length >= 20
462
- && hasAnchor;
463
- if (isSubstantive) {
464
- // Genuine scope expansion with concrete file references → accept.
465
- return makeEnforcementResult({
466
- action: "accept",
467
- reason: "",
468
- fix_instructions: "",
469
- check: "round_scope_drift",
470
- clarification_accepted: true,
471
- });
472
- }
473
479
  if (consecutiveRejections >= 2) {
474
480
  return makeEnforcementResult({
475
481
  action: "terminate",
476
- reason: `Repeated scope drift without substantive drift_clarification — ` +
477
- `the agent keeps changing files outside its declared contract scope.`,
482
+ reason: `Repeated scope drift — the agent keeps changing files outside its ` +
483
+ `declared contract scope.`,
478
484
  check: "round_scope_drift",
479
485
  });
480
486
  }
@@ -497,9 +503,8 @@ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries
497
503
  "a replacement contract is IGNORED while the current one is active, so " +
498
504
  "resubmit this work with outcome: \"blocked\" (blocker naming the " +
499
505
  "too-narrow scope) plus the extended scope as the next round's " +
500
- "round_contract, and explain the pivot in drift_clarification with " +
501
- "concrete file paths (≥ 20 characters, real anchors). Weak or missing " +
502
- "clarifications are rejected; repeated scope drift terminates the loop." +
506
+ "round_contract. Scope drift is a machine fact — no explanation waives " +
507
+ "it. Repeated scope drift terminates the loop." +
503
508
  escalation,
504
509
  check: "round_scope_drift",
505
510
  });
@@ -529,27 +534,57 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
529
534
  return makeEnforcementResult({
530
535
  action: "backtrack",
531
536
  reason: "The workspace was not restored after backtrack: " + flag.detail,
532
- fix_instructions: "Restore the working directory to the clean round's state " +
533
- "(git checkout -- . && git clean -fd, or git stash; if the failed " +
534
- "rounds created commits, git reset --hard <restore-commit>) before " +
535
- "submitting the next round.",
537
+ // v3.8: the requirement, not the recipe — LoopForge never mutates the
538
+ // working tree and does not prescribe how the agent restores it.
539
+ fix_instructions: "Return the working directory to the clean round's state before " +
540
+ "submitting the next round: the skipped-round files must be reverted " +
541
+ "and HEAD must be back at the restore point the backtrack prompt named.",
536
542
  check: "backtrack_workspace_not_restored",
537
543
  });
538
544
  }
539
545
  /** Shared observation pipeline for the progress evaluator (stall window,
540
546
  * flatline tier). Machine git motion is exculpatory only: it may veto a
541
547
  * self-reported stall but can never create one. */
542
- function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
548
+ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries,
549
+ /** v3.8.1: the round's success is machine-verified (`deriveEvidenceStatus`).
550
+ * A verified round is finishing work; a motion window cannot outvote it. */
551
+ machineBackedSuccess) {
552
+ // A round the machine verified is not churn, whatever the window says. This
553
+ // is the machine-backed replacement for the old self-reported `>= 0.95`
554
+ // near-completion guard: it lets a loop FINISH (a closing round that only
555
+ // runs the verification and declares success changes no files).
556
+ if (machineBackedSuccess)
557
+ return null;
543
558
  const window = tier === "progress_stall"
544
559
  ? 3
545
560
  : Math.max(1, getPolicy().engine.stall_lookback_rounds);
546
561
  if (currentRound < window)
547
562
  return null;
548
563
  const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
564
+ const threshold = getPolicy().evolution.progress_stall_threshold;
549
565
  let stalled = false;
550
566
  let detail = "";
551
- const threshold = getPolicy().evolution.progress_stall_threshold;
552
- if (progressByRound.size >= window) {
567
+ // v3.8.1: when the MACHINE series is observable it decides the verdict, and
568
+ // the agent's own estimates are not consulted at all. Reading the estimates
569
+ // first (the pre-3.8.1 order) let an agent cancel a stall by writing a
570
+ // rising `progress_estimate` every round — a verdict about machine motion
571
+ // that the agent's own report could move, which is the one thing an external
572
+ // verifier must not allow. Machine motion still only ever EXCUSES: it can
573
+ // veto a stall it did not create; it can never create one.
574
+ //
575
+ // The self-reported window is the FALLBACK for a loop with no machine
576
+ // history to read (no git provider, or fewer rounds than the window). There
577
+ // the evaluator can only catch a stall the agent's own reports state, and
578
+ // the near-completion guard keeps it from rejecting a loop that reports
579
+ // itself nearly done.
580
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
581
+ if (machine !== null) {
582
+ stalled = machine.every((value) => !value);
583
+ detail = tier === "progress_stall"
584
+ ? `no machine-observed changes in the last ${window} rounds`
585
+ : `no machine-observed changes for ${window} consecutive rounds`;
586
+ }
587
+ else if (progressByRound.size >= window) {
553
588
  const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
554
589
  if (recent[0] < currentRound - window)
555
590
  return null;
@@ -558,7 +593,8 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
558
593
  return null;
559
594
  if (tier === "progress_stall") {
560
595
  stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
561
- detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ");
596
+ detail = `${values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ")}` +
597
+ " (agent-reported, no machine history to read)";
562
598
  }
563
599
  else {
564
600
  const first = values[0];
@@ -567,22 +603,10 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
567
603
  }
568
604
  }
569
605
  else {
570
- const machine = machineProgressSeries(vaultEntries, currentRound, window);
571
- if (machine === null)
572
- return null;
573
- stalled = machine.every((value) => !value);
574
- detail = tier === "progress_stall"
575
- ? `no machine-observed changes in the last ${window} rounds`
576
- : `no machine-observed changes for ${window} consecutive rounds`;
606
+ return null;
577
607
  }
578
608
  if (!stalled)
579
609
  return null;
580
- const machine = machineProgressSeries(vaultEntries, currentRound, window);
581
- if (machine?.some(Boolean))
582
- return null;
583
- if (machine !== null) {
584
- detail += ` — no machine-observed git motion in rounds ${currentRound - window}–${currentRound - 1}`;
585
- }
586
610
  return { tier, detail, window, threshold };
587
611
  }
588
612
  /** Shared progress escalation and backtrack deadlock guard (v3.7: one
@@ -656,9 +680,9 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
656
680
  * shorter lookback configs), where it applies its steeper disposition
657
681
  * ladder. Machine git motion is exculpatory only — it may veto a
658
682
  * self-reported stall but can never create one. */
659
- function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
660
- const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries)
661
- ?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
683
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess) {
684
+ const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries, machineBackedSuccess)
685
+ ?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries, machineBackedSuccess);
662
686
  return verdict
663
687
  ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
664
688
  : null;
@@ -706,212 +730,6 @@ function enforceMaxRejections(consecutiveRejections) {
706
730
  check: "max_rejections",
707
731
  });
708
732
  }
709
- /** v2.12: Check if a drift_clarification contains at least one semantic
710
- * anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
711
- * reference. This distinguishes genuine explanations from filler text that
712
- * just happens to be ≥ 20 characters. */
713
- /** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
714
- * file-path-like tokens. */
715
- function extractAnchors(clarification) {
716
- const anchors = new Set();
717
- for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
718
- anchors.add(match[0].toLowerCase());
719
- }
720
- for (const path of extractFilePathTokens(clarification)) {
721
- anchors.add(path);
722
- }
723
- return [...anchors];
724
- }
725
- /** v2.12: A drift clarification is substantive only when at least one anchor
726
- * is REAL — a stable ID that derives from a known constraint/criterion/
727
- * sub-goal text in the vault, or a file path present in reported/observed
728
- * files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
729
- * count, closing the v2.12 filler-text loophole. */
730
- function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
731
- const anchors = extractAnchors(clarification);
732
- if (anchors.length === 0)
733
- return false;
734
- const knownFiles = new Set();
735
- const knownTexts = new Set();
736
- const collect = (values) => {
737
- if (!Array.isArray(values))
738
- return;
739
- for (const value of values) {
740
- if (typeof value === "string" && value.length > 0)
741
- knownTexts.add(value);
742
- }
743
- };
744
- // The agent's own round report is a known set: its constraints, criteria,
745
- // and files are legitimate anchor targets.
746
- for (const file of selfEval.execution_evidence?.files_changed ?? [])
747
- knownFiles.add(file);
748
- collect(selfEval.discovered_constraints);
749
- collect(selfEval.constraint_violations);
750
- const selfEvidence = selfEval.execution_evidence;
751
- if (selfEvidence) {
752
- collect(selfEvidence.success_criteria_met);
753
- collect(selfEvidence.success_criteria_remaining);
754
- }
755
- for (const entry of vaultEntries) {
756
- collect(entry.constraint_violations);
757
- collect(entry.discovered_constraints);
758
- const ev = entry.execution_evidence;
759
- if (ev) {
760
- collect(ev.files_changed);
761
- collect(ev.success_criteria_met);
762
- collect(ev.success_criteria_remaining);
763
- }
764
- }
765
- for (const anchor of anchors) {
766
- if (STABLE_ID_RE.test(anchor)) {
767
- const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
768
- ? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
769
- : anchor.startsWith("cr-")
770
- ? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
771
- : [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
772
- if (matches)
773
- return true;
774
- continue;
775
- }
776
- // File path: exact or suffix match against known files.
777
- if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
778
- return true;
779
- }
780
- }
781
- return false;
782
- }
783
- /** Build a stronger rejection notice as the clarification streak grows. */
784
- function buildClarificationRejectionNotice(streak) {
785
- const base = "Your declared next_action from the previous round does not match " +
786
- "what you actually did this round. You must either: " +
787
- "(a) explain why you pivoted in the drift_clarification field, " +
788
- "mentioning specific constraint/criterion/sub-goal IDs or file paths " +
789
- "that prompted the change, or " +
790
- "(b) redo this round and do what you said you would do.";
791
- if (streak >= 2) {
792
- return (base +
793
- "\n\n### ⚠️ Repeated Weak Clarification\n\n" +
794
- `You have submitted drift_clarification without concrete references ` +
795
- `for ${streak} consecutive rounds. Your explanation MUST include at ` +
796
- `least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
797
- `sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
798
- `🚨 If the next clarification also lacks concrete anchors, the loop ` +
799
- `will be terminated.`);
800
- }
801
- return base;
802
- }
803
- /** R7: Agent declared a next_action but did something unrelated.
804
- *
805
- * v2.8: When the agent provides a substantive drift_clarification (≥ 20
806
- * characters), the rejection is waived — the agent has acknowledged and
807
- * explained the pivot. An empty or absent clarification still triggers
808
- * the normal reject/terminate path.
809
- *
810
- * v2.12: Clarification now requires a semantic anchor (constraint/criterion/
811
- * sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
812
- * clarifications (no anchors) increment a streak counter; 3 consecutive
813
- * weak clarifications terminate the loop.
814
- *
815
- * First occurrence → reject (agent must explain the gap or realign).
816
- * Second consecutive → terminate (agent cannot/will not follow its own plan). */
817
- function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
818
- const flag = flags.find((f) => f.check === "intent_drift");
819
- if (!flag)
820
- return null;
821
- const clarification = selfEval.drift_clarification?.trim();
822
- // v2.12: Substantive anchors — the referenced ID/path must actually exist
823
- // in the vault or in reported files; a fabricated ID no longer counts.
824
- const hasAnchor = clarification
825
- ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
826
- : false;
827
- // v2.12: Substantive clarification = length check + semantic anchor
828
- const isSubstantive = clarification
829
- && clarification.length >= 20
830
- && hasAnchor;
831
- if (isSubstantive) {
832
- // Genuine explanation with concrete references → accept the pivot
833
- return makeEnforcementResult({
834
- action: "accept",
835
- reason: "",
836
- fix_instructions: "",
837
- check: "intent_drift",
838
- clarification_accepted: true,
839
- });
840
- }
841
- // v2.12: Clarification present but weak (no semantic anchors)
842
- if (clarification && clarification.length >= 20 && !hasAnchor) {
843
- const maxStreak = getPolicy().engine.drift_clarification_max_streak;
844
- // When maxStreak is 0, disable the check (pre-v2.12 behavior)
845
- if (maxStreak <= 0) {
846
- return makeEnforcementResult({
847
- action: "accept",
848
- reason: "",
849
- fix_instructions: "",
850
- check: "intent_drift",
851
- clarification_accepted: true,
852
- });
853
- }
854
- const newStreak = driftClarificationStreak + 1;
855
- if (newStreak >= maxStreak) {
856
- return makeEnforcementResult({
857
- action: "terminate",
858
- reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
859
- `file references) for ${newStreak} consecutive rounds. The agent ` +
860
- `systematically avoids following its own plan without substantive ` +
861
- `explanation.`,
862
- check: "intent_drift",
863
- });
864
- }
865
- return makeEnforcementResult({
866
- action: "reject",
867
- reason: flag.detail,
868
- fix_instructions: buildClarificationRejectionNotice(newStreak),
869
- check: "intent_drift",
870
- });
871
- }
872
- // No clarification or too short — the worst form of drift. v2.14: counts
873
- // toward the SAME streak as weak clarifications (maxStreak consecutive
874
- // un-explained drifts terminate, per the README's "three consecutive weak
875
- // clarifications"). Previously this branch used the GLOBAL
876
- // consecutiveRejections counter, so an unrelated earlier rejection
877
- // (e.g. R1) terminated the loop on the very first drift.
878
- const maxStreak = getPolicy().engine.drift_clarification_max_streak;
879
- // When maxStreak is 0, disable the check (pre-v2.12 behavior)
880
- if (maxStreak <= 0) {
881
- return makeEnforcementResult({
882
- action: "accept",
883
- reason: "",
884
- fix_instructions: "",
885
- check: "intent_drift",
886
- clarification_accepted: true,
887
- });
888
- }
889
- const newStreak = driftClarificationStreak + 1;
890
- if (newStreak >= maxStreak) {
891
- return makeEnforcementResult({
892
- action: "terminate",
893
- reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
894
- `consecutive rounds without any drift_clarification. The agent ` +
895
- `cannot or will not follow its own plan — terminating loop.`,
896
- check: "intent_drift",
897
- });
898
- }
899
- return makeEnforcementResult({
900
- action: "reject",
901
- reason: flag.detail,
902
- fix_instructions: "Your declared next_action from the previous round does not match " +
903
- "what you actually did this round. You must either: " +
904
- "(a) explain why you pivoted and what you learned in the " +
905
- "drift_clarification field, or " +
906
- "(b) redo this round and do what you said you would do. " +
907
- "If you intentionally changed direction, update your objective_refinement " +
908
- "and set a new next_action that reflects the new plan.",
909
- check: "intent_drift",
910
- });
911
- }
912
- // ═══════════════════════════════════════════════════════════════════════════
913
- // Escalation notice builder (v2.7)
914
- // ═══════════════════════════════════════════════════════════════════════════
915
733
  /** Build an escalation notice urging the agent to seek human guidance.
916
734
  *
917
735
  * Appended to fix_instructions when a rule would otherwise terminate.
@@ -962,24 +780,21 @@ function buildDiagnosticGap(flags) {
962
780
  return lines.join("\n");
963
781
  }
964
782
  const RULE_TABLE = [
965
- // R1/R2 join the contradiction umbrella per the documented four-class model
966
- // (AGENTS/README/CHANGELOG): a row's category is its enforcement action
967
- // class, NOT the trigger check's gate domain — claim-vs-self-reported-facts
968
- // rows (outcome_success_contradiction included) belong to this class too.
969
- { category: "evidence_contradiction", checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
970
- { category: "evidence_contradiction", checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
971
- { category: "evidence_contradiction", checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
972
- { category: "evidence_contradiction", checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
973
- { category: "contract_scope", checks: ["contract_completion_unverified"], ladder: "internal" },
974
- { category: "contract_scope", checks: ["premature_boundary"], ladder: "internal" },
975
- { category: "evidence_contradiction", checks: ["success_without_verified_evidence"], ladder: "internal" },
976
- { category: "contract_scope", checks: ["round_scope_drift"], ladder: "internal", clarificationAllowed: true },
977
- { category: "progress_recovery", checks: ["progress_stall"], ladder: "internal", backtrackAllowed: true },
978
- { category: "progress_recovery", checks: ["max_rejections"], ladder: "counter" },
979
- { category: "plan_drift", checks: ["intent_drift"], ladder: "internal", clarificationAllowed: true },
980
- { category: "progress_recovery", checks: ["backtrack_workspace_not_restored"], ladder: "internal", backtrackAllowed: true },
783
+ { checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
784
+ { checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
785
+ { checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
786
+ { checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
787
+ { checks: ["success_without_verified_evidence"], ladder: "internal" },
788
+ { checks: ["round_scope_drift"], ladder: "internal" },
789
+ // v3.8.1: "internal" — the handler owns this row's ladder (it must terminate
790
+ // as `incomplete`, with its own reason). Declaring it uniform as well ran the
791
+ // escalation notice twice and left the uniform terminate branch dead.
792
+ { checks: ["contract_items_unverified"], ladder: "internal" },
793
+ { checks: ["progress_stall"], ladder: "internal" },
794
+ { checks: ["max_rejections"], ladder: "counter" },
795
+ { checks: ["backtrack_workspace_not_restored"], ladder: "internal" },
981
796
  // v3.7.1: cited gates without an approved human decision
982
- { category: "contract_scope", checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
797
+ { checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
983
798
  ];
984
799
  const RULE_TABLE_BY_ID = new Map();
985
800
  for (const row of RULE_TABLE) {
@@ -1024,8 +839,6 @@ function enforceUserGateUnresolved(flags) {
1024
839
  });
1025
840
  }
1026
841
  export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
1027
- /** v2.12: Current clarification streak for R7 escalation. */
1028
- driftClarificationStreak = 0,
1029
842
  /** L4 (v3.7.x): which check rejected the PREVIOUS round. The coordinator
1030
843
  * resets consecutiveRejections to 1 whenever the check changes, so the
1031
844
  * counter only ever measures one check's streak — this field names that
@@ -1049,27 +862,25 @@ lastRejectionCheck = "") {
1049
862
  // v3.5: contract completion claimed without its verification_plan
1050
863
  // commands passing — runs before R-C1: the completion-truth question
1051
864
  // outranks boundary nuance.
1052
- () => enforceContractCompletionUnverified(flags, consecutiveRejections),
1053
865
  // v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
1054
866
  // wording and check counting; runs before R8 so contract rounds get the
1055
867
  // contract's own reason.)
1056
- () => enforcePrematureBoundary(flags, consecutiveRejections),
1057
868
  // v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
1058
869
  // arm merged from the former R3 empty_success posture + claims arm)
1059
870
  () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
1060
871
  // v3.3: R-C2 — files changed outside the contract's declared scope
1061
- () => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
872
+ () => enforceScopeDrift(flags, consecutiveRejections),
873
+ // v3.8: verification debt — contract items claimed met without machine
874
+ // verification (fires only after the configured streak)
875
+ () => enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck),
1062
876
  // v3.7: single progress evaluator (former R4/R5 slots merged) —
1063
877
  // reject → backtrack → terminate with the deadlock guard
1064
- () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
878
+ () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess),
1065
879
  // R6: rejection-counter catch-all (evaluated only when no row above
1066
880
  // fired — a clean-looking round carrying a high persisted counter)
1067
881
  // v3.7.1: cited gate without an approved human decision
1068
882
  () => enforceUserGateUnresolved(flags),
1069
883
  () => enforceMaxRejections(consecutiveRejections),
1070
- // v2.12: R7 — intent drift; its weak-clarification streak is a separate
1071
- // counter and never mixes with the global rejection count
1072
- () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1073
884
  // v2.12: R9 — post-backtrack workspace not restored
1074
885
  () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1075
886
  ];