loopforge 3.2.1 → 3.6.0

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 (112) hide show
  1. package/README.md +67 -67
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +7 -34
  4. package/dist/audit.js.map +1 -1
  5. package/dist/canonical-state.d.ts +71 -4
  6. package/dist/canonical-state.d.ts.map +1 -1
  7. package/dist/canonical-state.js +176 -9
  8. package/dist/canonical-state.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +16 -18
  11. package/dist/cli.js.map +1 -1
  12. package/dist/cognitive-facts.d.ts +17 -0
  13. package/dist/cognitive-facts.d.ts.map +1 -0
  14. package/dist/cognitive-facts.js +99 -0
  15. package/dist/cognitive-facts.js.map +1 -0
  16. package/dist/committed-round.d.ts +59 -0
  17. package/dist/committed-round.d.ts.map +1 -0
  18. package/dist/committed-round.js +222 -0
  19. package/dist/committed-round.js.map +1 -0
  20. package/dist/enforcement-gate.d.ts +4 -2
  21. package/dist/enforcement-gate.d.ts.map +1 -1
  22. package/dist/enforcement-gate.js +339 -301
  23. package/dist/enforcement-gate.js.map +1 -1
  24. package/dist/engine.d.ts +16 -4
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +63 -15
  27. package/dist/engine.js.map +1 -1
  28. package/dist/evidence-claims.d.ts.map +1 -1
  29. package/dist/evidence-claims.js +21 -39
  30. package/dist/evidence-claims.js.map +1 -1
  31. package/dist/evidence-provider.d.ts +29 -0
  32. package/dist/evidence-provider.d.ts.map +1 -1
  33. package/dist/evidence-provider.js +93 -16
  34. package/dist/evidence-provider.js.map +1 -1
  35. package/dist/index.d.ts +3 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +21 -4
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +244 -66
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-projection.d.ts +5 -25
  44. package/dist/loop-projection.d.ts.map +1 -1
  45. package/dist/loop-projection.js +18 -134
  46. package/dist/loop-projection.js.map +1 -1
  47. package/dist/loop-store.d.ts +5 -2
  48. package/dist/loop-store.d.ts.map +1 -1
  49. package/dist/loop-store.js.map +1 -1
  50. package/dist/mcp/round-lifecycle.d.ts +6 -8
  51. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  52. package/dist/mcp/round-lifecycle.js +61 -52
  53. package/dist/mcp/round-lifecycle.js.map +1 -1
  54. package/dist/mcp/server.js +3 -3
  55. package/dist/mcp/server.js.map +1 -1
  56. package/dist/mcp/session.d.ts +14 -3
  57. package/dist/mcp/session.d.ts.map +1 -1
  58. package/dist/mcp/session.js +174 -52
  59. package/dist/mcp/session.js.map +1 -1
  60. package/dist/mcp/tools.d.ts +12 -0
  61. package/dist/mcp/tools.d.ts.map +1 -1
  62. package/dist/mcp/tools.js +160 -52
  63. package/dist/mcp/tools.js.map +1 -1
  64. package/dist/policy-metrics.d.ts +0 -7
  65. package/dist/policy-metrics.d.ts.map +1 -1
  66. package/dist/policy-metrics.js +15 -55
  67. package/dist/policy-metrics.js.map +1 -1
  68. package/dist/policy.d.ts +22 -20
  69. package/dist/policy.d.ts.map +1 -1
  70. package/dist/policy.js +24 -38
  71. package/dist/policy.js.map +1 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +120 -140
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +57 -23
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +10 -4
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts.map +1 -1
  80. package/dist/replay.js +21 -22
  81. package/dist/replay.js.map +1 -1
  82. package/dist/round-contract.d.ts +64 -0
  83. package/dist/round-contract.d.ts.map +1 -0
  84. package/dist/round-contract.js +80 -0
  85. package/dist/round-contract.js.map +1 -0
  86. package/dist/round-coordinator.d.ts +5 -3
  87. package/dist/round-coordinator.d.ts.map +1 -1
  88. package/dist/round-coordinator.js +33 -29
  89. package/dist/round-coordinator.js.map +1 -1
  90. package/dist/round-driver.d.ts +0 -1
  91. package/dist/round-driver.d.ts.map +1 -1
  92. package/dist/round-driver.js +0 -1
  93. package/dist/round-driver.js.map +1 -1
  94. package/dist/round-transaction.d.ts +0 -1
  95. package/dist/round-transaction.d.ts.map +1 -1
  96. package/dist/round-transaction.js +0 -1
  97. package/dist/round-transaction.js.map +1 -1
  98. package/dist/self-eval.d.ts +29 -34
  99. package/dist/self-eval.d.ts.map +1 -1
  100. package/dist/self-eval.js +112 -148
  101. package/dist/self-eval.js.map +1 -1
  102. package/dist/verification-gate.d.ts +53 -12
  103. package/dist/verification-gate.d.ts.map +1 -1
  104. package/dist/verification-gate.js +462 -90
  105. package/dist/verification-gate.js.map +1 -1
  106. package/dist/workspace.d.ts +29 -0
  107. package/dist/workspace.d.ts.map +1 -0
  108. package/dist/workspace.js +61 -0
  109. package/dist/workspace.js.map +1 -0
  110. package/loop_policy.json +5 -13
  111. package/package.json +1 -1
  112. package/skills/perception/SKILL.md +135 -136
@@ -17,35 +17,23 @@
17
17
  * with stopReason "enforcement_terminated".
18
18
  */
19
19
  import { makeEnforcementResult } from "./protocol.js";
20
- import { entryRound, 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 } from "./verification-gate.js";
20
+ import { entryRound, 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 } from "./verification-gate.js";
21
21
  import { effectiveSuccess } from "./self-eval.js";
22
22
  import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
23
23
  import { getPolicy } from "./policy.js";
24
24
  import { STABLE_ID_RE, isRecord } from "./token-utils.js";
25
- import { isProcessResult } from "./round-transaction.js";
25
+ import { committedRoundsFromEntries, decodeCommittedRound, } from "./committed-round.js";
26
26
  // ═══════════════════════════════════════════════════════════════════════════
27
27
  // Helpers
28
28
  // ═══════════════════════════════════════════════════════════════════════════
29
- /** Extract progress_estimate from a vault entry's execution_evidence.
30
- * Returns null if no execution evidence is available. */
31
- function entryProgress(entry) {
32
- const evidence = entry.execution_evidence;
33
- if (!evidence)
34
- return null;
35
- const pe = evidence.progress_estimate;
36
- return typeof pe === "number" ? pe : null;
37
- }
38
29
  /** Collect progress estimates from vault entries into a round→estimate map.
39
- * Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
30
+ * Shared by R4 (progress_stall) and R5 (progress_flatline). */
40
31
  function collectProgressByRound(vaultEntries, currentRound) {
41
32
  const progressByRound = new Map();
42
- for (const entry of vaultEntries) {
43
- const rnd = entryRound(entry);
44
- if (rnd < 1 || rnd >= currentRound)
45
- continue;
46
- const pe = entryProgress(entry);
47
- if (pe !== null)
48
- progressByRound.set(rnd, pe);
33
+ for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
34
+ const progress = round.executionEvidence?.progress_estimate;
35
+ if (typeof progress === "number")
36
+ progressByRound.set(round.round, progress);
49
37
  }
50
38
  return progressByRound;
51
39
  }
@@ -73,13 +61,7 @@ function progressWindow(vaultEntries, currentRound, selfEval) {
73
61
  * decision under loop_lineage.round_transaction; lineage entries carry
74
62
  * nothing at the top level. */
75
63
  function committedRoundResult(entry) {
76
- const lineage = isRecord(entry.loop_lineage) ? entry.loop_lineage : null;
77
- const rt = lineage && isRecord(lineage.round_transaction)
78
- ? lineage.round_transaction
79
- : null;
80
- if (!rt)
81
- return null;
82
- return isProcessResult(rt.result) ? rt.result : null;
64
+ return decodeCommittedRound(entry)?.result ?? null;
83
65
  }
84
66
  /** True when a backtrack already committed at or after the current round —
85
67
  * i.e. the loop is re-walking territory the system already rolled back
@@ -91,7 +73,7 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
91
73
  return committedRoundResult(entry)?.action === "backtrack";
92
74
  });
93
75
  }
94
- /** Check whether a vault entry represents a "clean" round that can serve
76
+ /** Check whether a committed round represents a clean restore point.
95
77
  * as a safe restore point. A clean round was accepted (not rejected)
96
78
  * and had no error-level verification flags.
97
79
  *
@@ -101,61 +83,30 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
101
83
  * valid backtrack target (the most common backtrack trigger is exactly a
102
84
  * run of such rounds). Rejected/terminated rounds never commit, and
103
85
  * rolled-back rounds are excluded below. */
104
- function isCleanRound(entry) {
105
- const committed = committedRoundResult(entry);
106
- // A rolled-back round (already backtracked) is not a valid restore point.
107
- if (committed?.action === "backtrack")
108
- return false;
109
- // Rejected/terminated rounds are not clean. (They never commit in the
110
- // real vault — this guards legacy or mixed-shape data.)
111
- if (committed && (committed.action === "reject" || committed.action === "terminate")) {
112
- return false;
113
- }
114
- // Rounds with error-level verification flags are not clean
115
- const flags = Array.isArray(entry.verification_flags)
116
- ? entry.verification_flags
117
- : committed?.verificationFlags ?? [];
118
- for (const f of flags) {
119
- if (f !== null && typeof f === "object" &&
120
- f.severity === "error")
121
- return false;
122
- }
123
- return true;
86
+ function isCleanRound(round) {
87
+ return !round.verificationFlags.some((flag) => flag.severity === "error");
124
88
  }
125
89
  /** Scan backwards from currentRound to find the most recent clean round.
126
90
  * Collects discovered_constraints from skipped rounds along the way.
127
91
  * Returns null when no clean round is found within maxDepth. */
128
92
  export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
129
- // v3.2.1: exclude compile-time lineage entries — every committed round
130
- // writes one (engine.ts hardcodes `success: true` on it, and it carries no
131
- // round_transaction), and it precedes the feedback entry in the flat view.
132
- // Treating it as a decision made every round look clean, so the restore
133
- // point was always currentRound-1: dirty/rejected rounds were never
134
- // skipped, depth > 1 never collected skippedDiscoveries, and the restore
135
- // prompt's file list (round-coordinator) was always empty.
136
- const completed = vaultEntries
137
- .filter((e) => {
138
- const rnd = entryRound(e);
139
- return rnd >= 1 && rnd < currentRound && e.task_type !== "loop_lineage";
140
- })
141
- .sort((a, b) => entryRound(b) - entryRound(a)); // newest first
93
+ const completed = committedRoundsFromEntries(vaultEntries, currentRound)
94
+ .sort((a, b) => b.round - a.round);
142
95
  const skippedDiscoveries = [];
143
96
  for (let depth = 1; depth <= maxDepth; depth++) {
144
97
  const targetRound = currentRound - depth;
145
98
  if (targetRound < 1)
146
99
  break;
147
- const entry = completed.find((e) => entryRound(e) === targetRound);
148
- if (!entry)
100
+ const round = completed.find((item) => item.round === targetRound);
101
+ if (!round)
149
102
  continue;
150
103
  // Collect discoveries from rounds we're about to skip (depth > 1)
151
104
  if (depth > 1) {
152
105
  for (let d = 1; d < depth; d++) {
153
106
  const skippedRound = currentRound - d;
154
- const skippedEntry = completed.find((e) => entryRound(e) === skippedRound);
155
- if (skippedEntry) {
156
- const discovered = Array.isArray(skippedEntry.discovered_constraints)
157
- ? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
158
- : [];
107
+ const skippedEntry = completed.find((item) => item.round === skippedRound);
108
+ if (skippedEntry?.evaluation) {
109
+ const discovered = skippedEntry.evaluation.discovered_constraints ?? [];
159
110
  for (const item of discovered) {
160
111
  if (!skippedDiscoveries.includes(item))
161
112
  skippedDiscoveries.push(item);
@@ -163,7 +114,7 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
163
114
  }
164
115
  }
165
116
  }
166
- if (isCleanRound(entry)) {
117
+ if (isCleanRound(round)) {
167
118
  return { round: targetRound, skippedDiscoveries };
168
119
  }
169
120
  }
@@ -174,23 +125,13 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
174
125
  * evidence as fallback). Returns null when the round has no git snapshot
175
126
  * or no committed feedback entry. */
176
127
  export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
177
- const feedback = vaultEntries.find((entry) => String(entry.task_id ?? "") === `loop:${entry.loop_id}:r${restoreRound}:feedback`);
178
- const lineage = feedback && isRecord(feedback.loop_lineage)
179
- ? feedback.loop_lineage
180
- : null;
181
- const transaction = lineage && isRecord(lineage.round_transaction)
182
- ? lineage.round_transaction
183
- : null;
184
- const snapshot = transaction && isRecord(transaction.snapshot)
185
- ? transaction.snapshot
186
- : null;
187
- if (!snapshot)
128
+ const committed = committedRoundsFromEntries(vaultEntries)
129
+ .find((view) => view.round === restoreRound);
130
+ if (!committed)
188
131
  return null;
189
- const evidence = Array.isArray(snapshot.afterEvidence) && snapshot.afterEvidence.length > 0
190
- ? snapshot.afterEvidence
191
- : Array.isArray(snapshot.beforeEvidence)
192
- ? snapshot.beforeEvidence
193
- : [];
132
+ const evidence = committed.afterEvidence.length > 0
133
+ ? committed.afterEvidence
134
+ : committed.beforeEvidence;
194
135
  for (const item of evidence) {
195
136
  if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
196
137
  continue;
@@ -222,7 +163,7 @@ gitHead) {
222
163
  `The work done in those rounds produced no verifiable progress — ` +
223
164
  `no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
224
165
  }
225
- else if (triggerRule === "progress_stall_terminal") {
166
+ else if (triggerRule === "progress_flatline") {
226
167
  lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
227
168
  `The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
228
169
  }
@@ -255,7 +196,16 @@ gitHead) {
255
196
  "> with unrestored files will be **rejected**.", "");
256
197
  // ── What Must Change ──────────────────────────────────────────────────
257
198
  lines.push("### What Must Change", "", "- Do **NOT** repeat the approach used in the skipped rounds.", "- Try a **different** task decomposition or technique.", "- If the current sub-goal is stuck, consider canceling it " +
258
- "(`canceled_subtasks`) and working on a different one.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
199
+ "(`canceled_subtasks`) and working on a different one.",
200
+ // v3.5: the restored Current Task may be the very Round Contract that
201
+ // stalled — the agent must be told the sanctioned way to revise it
202
+ // (outcome=blocked closes the active contract; the revised proposal in
203
+ // the same submission becomes active next round). Without this the
204
+ // agent's revision would be silently ignored as premature.
205
+ "- If the restored Current Task is a Round Contract that caused the stall: " +
206
+ "close it in this submission with `outcome=\"blocked\"` (+ a blocker " +
207
+ "explaining why it stalled) and declare the REVISED contract in the " +
208
+ "same submission. Do **NOT** silently restate the stalled contract.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
259
209
  "to flag anything you don't understand about the task or the blockage.", "");
260
210
  // ── Preserved Discoveries ─────────────────────────────────────────────
261
211
  if (skippedDiscoveries.length > 0) {
@@ -333,13 +283,33 @@ function enforceEvidenceContradiction(flags) {
333
283
  check: contradiction.check,
334
284
  });
335
285
  }
286
+ /** v3.3: R-EVID-VERIFY — the verification command's entrypoint changed in
287
+ * the same round the command ran. The runtime executed a script the agent
288
+ * just rewrote, so the "machine observation" has no stable baseline and
289
+ * cannot back the success claim. Reject so the agent resubmits the SAME
290
+ * round with the entrypoint unchanged (a freshly created entrypoint passes
291
+ * on the redo — the diff no longer contains it). */
292
+ function enforceVerificationEntrypointTampered(flags) {
293
+ const flag = flags.find((f) => f.check === CHECK_VERIFICATION_ENTRYPOINT_MODIFIED && f.severity === "error");
294
+ if (!flag)
295
+ return null;
296
+ return makeEnforcementResult({
297
+ action: "reject",
298
+ reason: flag.detail,
299
+ fix_instructions: "The verification command entrypoint changed in this round, so its " +
300
+ "result cannot be trusted as machine evidence. You must: (a) keep the " +
301
+ "verification command (and its entrypoint files) stable, (b) resubmit " +
302
+ "your SelfEvaluation for the SAME round with the entrypoint unchanged " +
303
+ "so the runtime can re-run it against a stable baseline, and (c) do " +
304
+ "NOT rewrite the verification script to make it pass.",
305
+ check: "verification_entrypoint_modified",
306
+ });
307
+ }
336
308
  /** R3: Agent claims success but did nothing verifiable.
337
309
  * v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
338
310
  * Missing evidence when success=true → reject (agent must provide evidence).
339
311
  * Empty evidence (no files + no tests) when success=true → reject. */
340
- function enforceEmptySuccess(selfEval, _flags, skipEvidenceRules = false) {
341
- if (skipEvidenceRules)
342
- return null; // Requires execution_evidence
312
+ function enforceEmptySuccess(selfEval, _flags) {
343
313
  if (!effectiveSuccess(selfEval))
344
314
  return null;
345
315
  const ev = selfEval.execution_evidence;
@@ -407,6 +377,131 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
407
377
  check: "success_without_verified_evidence",
408
378
  });
409
379
  }
380
+ /** R-C1 (v3.3): Round Contract boundary claimed prematurely. The gate
381
+ * produced premature_boundary (error): success claimed while done_when
382
+ * items were claimed met without machine-verified evidence or silently
383
+ * dropped. First occurrence → reject with contract-specific fix
384
+ * instructions; second consecutive → terminate (R8 ladder). */
385
+ function enforcePrematureBoundary(flags, consecutiveRejections) {
386
+ const flag = flags.find((f) => f.check === CHECK_PREMATURE_BOUNDARY && f.severity === "error");
387
+ if (!flag)
388
+ return null;
389
+ if (consecutiveRejections >= 2) {
390
+ return makeEnforcementResult({
391
+ action: "terminate",
392
+ reason: "Repeated premature Round Contract boundary claims — done_when items " +
393
+ "claimed without machine evidence or dropped while success is claimed.",
394
+ check: "premature_boundary",
395
+ });
396
+ }
397
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
398
+ consecutiveRejections >= 1
399
+ ? buildEscalationNotice()
400
+ : "";
401
+ return makeEnforcementResult({
402
+ action: "reject",
403
+ reason: flag.detail,
404
+ fix_instructions: "You claimed success under a Round Contract but done_when items are " +
405
+ "unfinished or unverified. You must: (a) run the contract's " +
406
+ "verification_plan commands (configured in loop_policy.json " +
407
+ "evidence.commands) and report their actual output, listing satisfied " +
408
+ "done_when items in success_criteria_met; or (b) set success=false and " +
409
+ "list what remains in success_criteria_remaining. " +
410
+ "Do NOT claim contract completion without machine evidence " +
411
+ "(no_change_reason does not apply to contract claims)." + escalation,
412
+ check: "premature_boundary",
413
+ });
414
+ }
415
+ /** v3.5: Round Contract completion claimed without its verification_plan
416
+ * commands passing this round. The gate produced
417
+ * contract_completion_unverified (error): the eval's met claims satisfy
418
+ * every done_when of the ACTIVE contract, but the machine never observed
419
+ * the plan's commands pass. Closing a contract is a success-class claim —
420
+ * first occurrence → reject with fix instructions; second consecutive →
421
+ * terminate (R-C1's ladder). Registered BEFORE R-C1: the completion-truth
422
+ * question outranks boundary nuance. */
423
+ function enforceContractCompletionUnverified(flags, consecutiveRejections) {
424
+ const flag = flags.find((f) => f.check === CHECK_CONTRACT_COMPLETION_UNVERIFIED && f.severity === "error");
425
+ if (!flag)
426
+ return null;
427
+ if (consecutiveRejections >= 2) {
428
+ return makeEnforcementResult({
429
+ action: "terminate",
430
+ reason: "Repeated unverified Round Contract completion claims — done_when items " +
431
+ "claimed complete while the verification_plan commands never passed.",
432
+ check: "contract_completion_unverified",
433
+ });
434
+ }
435
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
436
+ consecutiveRejections >= 1
437
+ ? buildEscalationNotice()
438
+ : "";
439
+ return makeEnforcementResult({
440
+ action: "reject",
441
+ reason: flag.detail,
442
+ fix_instructions: "You claimed completion under the ACTIVE Round Contract, but its " +
443
+ "verification_plan commands did not pass this round. You must: (a) fix " +
444
+ "the underlying failure, re-run the contract's verification_plan " +
445
+ "commands (configured in loop_policy.json evidence.commands), and list " +
446
+ "satisfied done_when items in success_criteria_met; or (b) set " +
447
+ "success=false and list what remains in success_criteria_remaining. " +
448
+ "Completion claims are never accepted without machine verification — " +
449
+ "do NOT use no_change_reason for completion claims." + escalation,
450
+ check: "contract_completion_unverified",
451
+ });
452
+ }
453
+ /** R-C2 (v3.3): Round Contract scope drift. The gate produced
454
+ * round_scope_drift (warn): files changed outside the contract's declared
455
+ * scope. A substantive drift_clarification (≥ 20 chars with real anchors —
456
+ * the out-of-scope file paths count) accepts the legitimately expanded
457
+ * scope; no/weak clarification rejects; repeated unclarified drift
458
+ * terminates. Mirrors R8's ladder, not R7's streak counter — no new
459
+ * session state. */
460
+ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries = []) {
461
+ const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
462
+ if (!flag)
463
+ return null;
464
+ const clarification = selfEval.drift_clarification?.trim();
465
+ const hasAnchor = clarification
466
+ ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
467
+ : false;
468
+ const isSubstantive = clarification !== undefined
469
+ && clarification.length >= 20
470
+ && hasAnchor;
471
+ if (isSubstantive) {
472
+ // Genuine scope expansion with concrete file references → accept.
473
+ return makeEnforcementResult({
474
+ action: "accept",
475
+ reason: "",
476
+ fix_instructions: "",
477
+ check: "round_scope_drift",
478
+ clarification_accepted: true,
479
+ });
480
+ }
481
+ if (consecutiveRejections >= 2) {
482
+ return makeEnforcementResult({
483
+ action: "terminate",
484
+ reason: `Repeated scope drift without substantive drift_clarification — ` +
485
+ `the agent keeps changing files outside its declared contract scope.`,
486
+ check: "round_scope_drift",
487
+ });
488
+ }
489
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
490
+ consecutiveRejections >= 1
491
+ ? buildEscalationNotice()
492
+ : "";
493
+ return makeEnforcementResult({
494
+ action: "reject",
495
+ reason: flag.detail,
496
+ fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
497
+ "(a) revert the out-of-scope changes, or (b) extend the scope in your " +
498
+ "re-declared round_contract AND explain the pivot in drift_clarification " +
499
+ "with concrete file paths (≥ 20 characters, real anchors). Weak or " +
500
+ "missing clarifications are rejected; repeated scope drift terminates " +
501
+ "the loop." + escalation,
502
+ check: "round_scope_drift",
503
+ });
504
+ }
410
505
  /** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
411
506
  * produced backtrack_workspace_not_restored (error): either skipped-round
412
507
  * files are still dirty, or the working tree is not at the restore point's
@@ -439,237 +534,124 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
439
534
  check: "backtrack_workspace_not_restored",
440
535
  });
441
536
  }
442
- /** R4: Progress has stalled for 3+ consecutive rounds.
443
- * Detected by checking progress_estimate deltas across vault entries.
444
- * First occurrence → REJECT (agent must change approach).
445
- * Second consecutive occurrence → TERMINATE (agent cannot recover). */
446
- function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
447
- // Need at least 3 rounds of history
448
- if (currentRound < 3)
537
+ /** Shared R4/R5 observation pipeline. Machine git motion is exculpatory
538
+ * only: it may veto a self-reported stall but can never create one. */
539
+ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
540
+ const window = tier === "progress_stall"
541
+ ? 3
542
+ : Math.max(1, getPolicy().engine.stall_lookback_rounds);
543
+ if (currentRound < window)
449
544
  return null;
450
- let isStalling = false;
451
- let stallDetail = "";
452
- let stallThreshold = 0;
453
- if (skipEvidenceRules) {
454
- // v3.2: heuristic path has no execution_evidence — fall back to machine
455
- // observations: three consecutive rounds with no git change = stall.
456
- // No git signal → keep the legacy skip (the machine cannot observe).
457
- const machine = machineProgressSeries(vaultEntries, currentRound, 3);
458
- if (machine === null)
545
+ const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
546
+ let stalled = false;
547
+ let detail = "";
548
+ const threshold = getPolicy().evolution.progress_stall_threshold;
549
+ if (progressByRound.size >= window) {
550
+ const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
551
+ if (recent[0] < currentRound - window)
459
552
  return null;
460
- isStalling = machine.every((value) => !value);
461
- stallDetail = "no machine-observed changes in the last 3 rounds";
553
+ const values = recent.map((round) => progressByRound.get(round));
554
+ if (values[values.length - 1] >= 0.95)
555
+ return null;
556
+ if (tier === "progress_stall") {
557
+ stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
558
+ detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ");
559
+ }
560
+ else {
561
+ const first = values[0];
562
+ stalled = values.every((value) => Math.abs(value - first) < 1e-10);
563
+ detail = `completely flat at ${(first * 100).toFixed(0)}%`;
564
+ }
462
565
  }
463
566
  else {
464
- const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
465
- if (progressByRound.size < 3)
466
- return null;
467
- const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
468
- const last3 = sortedRounds.slice(-3);
469
- // Require all three data points to be within [currentRound-3, currentRound-1].
470
- // (collectProgressByRound already filters rnd < currentRound, so the upper
471
- // bound is always satisfied — the lower bound is the real continuity guard.)
472
- const expectedMin = currentRound - 3;
473
- if (last3[0] < expectedMin)
567
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
568
+ if (machine === null)
474
569
  return null;
475
- const p1 = progressByRound.get(last3[0]);
476
- const p2 = progressByRound.get(last3[1]);
477
- const p3 = progressByRound.get(last3[2]);
478
- stallThreshold = getPolicy().evolution.progress_stall_threshold;
479
- const delta12 = p2 - p1;
480
- const delta23 = p3 - p2;
481
- // Both deltas must be below threshold AND not near completion
482
- isStalling = delta12 < stallThreshold && delta23 < stallThreshold && p3 < 0.95;
483
- stallDetail = `${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%`;
570
+ stalled = machine.every((value) => !value);
571
+ detail = tier === "progress_stall"
572
+ ? `no machine-observed changes in the last ${window} rounds`
573
+ : `no machine-observed changes for ${window} consecutive rounds`;
484
574
  }
485
- if (!isStalling)
575
+ if (!stalled)
486
576
  return null;
577
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
578
+ if (machine?.some(Boolean))
579
+ return null;
580
+ if (machine !== null) {
581
+ detail += ` — no machine-observed git motion in rounds ${currentRound - window}–${currentRound - 1}`;
582
+ }
583
+ return { tier, detail, window, threshold };
584
+ }
585
+ /** Shared R4/R5 escalation and backtrack deadlock guard. The flatline tier
586
+ * keeps its steeper threshold-0 ladder while using the same mechanics. */
587
+ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections) {
588
+ const { tier, detail, window, threshold } = verdict;
589
+ const flatline = tier === "progress_flatline";
487
590
  const escalation = getPolicy().engine.enforcement_escalation_enabled;
488
- // v2.7: When escalation is enabled, give one extra rejection round with a
489
- // "Seek Human Guidance" notice before terminating. This lets the agent
490
- // course-correct with human input rather than being killed immediately.
491
- if (consecutiveRejections >= 2) {
591
+ const stalledReason = flatline
592
+ ? `Progress has been ${detail} for ${window} consecutive rounds.`
593
+ : `Progress has stalled: ${detail} over the last ${window} rounds` +
594
+ `${threshold > 0 ? ` (delta < ${(threshold * 100).toFixed(0)}% each round)` : ""}.`;
595
+ if ((flatline && consecutiveRejections >= 1) || consecutiveRejections >= 2) {
492
596
  return makeEnforcementResult({
493
597
  action: "terminate",
494
- reason: `Progress stalled for 3+ rounds ` +
495
- `(${stallDetail}) ` +
496
- `and agent did not resolve after escalation. Terminating loop.`,
497
- check: "progress_stall",
598
+ reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
599
+ check: tier,
498
600
  });
499
601
  }
500
- if (consecutiveRejections >= 1 && escalation) {
501
- // v2.10: When backtrack is enabled, escalate to backtrack instead of
502
- // reject. The agent is rolled back to the last clean round with lessons
503
- // injected, rather than redoing the same round with an escalation notice.
504
- if (getPolicy().engine.backtrack_enabled) {
505
- // v2.14: A backtrack that already committed for this round (the loop
506
- // is re-walking rolled-back territory with the same stall) must not
507
- // loop forever — terminate instead. Without this, consecutive
508
- // rejections reset on every backtrack and the R4 cycle has no exit.
509
- if (hasCommittedBacktrack(vaultEntries, currentRound)) {
510
- return makeEnforcementResult({
511
- action: "terminate",
512
- reason: `Progress has stalled over the last 3 rounds ` +
513
- `(${stallDetail}) and a backtrack already fired for ` +
514
- `this round. The stall persists after rollback — terminating.`,
515
- check: "progress_stall",
516
- });
517
- }
602
+ if (consecutiveRejections >= 1 && !escalation) {
603
+ return makeEnforcementResult({
604
+ action: "terminate",
605
+ reason: `${stalledReason} The stall persisted after the previous rejection. Terminating loop.`,
606
+ check: tier,
607
+ });
608
+ }
609
+ const escalated = flatline ? escalation : consecutiveRejections >= 1 && escalation;
610
+ if (escalated && getPolicy().engine.backtrack_enabled) {
611
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
518
612
  return makeEnforcementResult({
519
- action: "backtrack",
520
- reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
521
- `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
522
- fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
523
- "to the last clean round. Do NOT repeat the approach that led to the stall. " +
524
- "Choose a different technique or task decomposition.",
525
- check: "progress_stall",
613
+ action: "terminate",
614
+ reason: `${stalledReason} A backtrack already fired for this round; the stall persists after rollback.`,
615
+ check: tier,
526
616
  });
527
617
  }
528
618
  return makeEnforcementResult({
529
- action: "reject",
530
- reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
531
- `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
532
- fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
533
- "(a) explain what is blocking progress, " +
534
- "(b) propose a DIFFERENT technique or task decomposition, and " +
535
- "(c) set a concrete, verifiable goal for the redo of this round. " +
536
- "Do NOT repeat the same approach — it has not moved progress forward." +
537
- buildEscalationNotice(),
538
- check: "progress_stall",
619
+ action: "backtrack",
620
+ reason: stalledReason,
621
+ fix_instructions: flatline
622
+ ? "Progress is exactly flat. After rollback, choose a radically different approach."
623
+ : "After rollback, do not repeat the stalled approach; choose a different technique or task decomposition.",
624
+ check: tier,
539
625
  });
540
626
  }
541
- if (consecutiveRejections >= 1) {
627
+ if (flatline && !escalation) {
542
628
  return makeEnforcementResult({
543
629
  action: "terminate",
544
- reason: `Progress stalled for 3+ rounds ` +
545
- `(${stallDetail}) ` +
546
- `and agent did not resolve after previous rejection. Terminating loop.`,
547
- check: "progress_stall",
630
+ reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
631
+ check: tier,
548
632
  });
549
633
  }
550
634
  return makeEnforcementResult({
551
635
  action: "reject",
552
- reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
553
- `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
554
- fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
555
- "(a) explain what is blocking progress, " +
556
- "(b) propose a DIFFERENT technique or task decomposition, and " +
557
- "(c) set a concrete, verifiable goal for the redo of this round. " +
558
- "Do NOT repeat the same approach — it has not moved progress forward.",
559
- check: "progress_stall",
636
+ reason: stalledReason,
637
+ fix_instructions: (flatline
638
+ ? "Explain the fundamental blocker, choose a radically different approach, and set a concrete verifiable goal."
639
+ : "Explain the blocker, choose a different technique or task decomposition, and set a concrete verifiable goal for this retry.") +
640
+ (escalated ? buildEscalationNotice() : ""),
641
+ check: tier,
560
642
  });
561
643
  }
562
- /** R5: Progress completely stalled (delta = 0) for N consecutive rounds.
563
- * Stricter than R4 (which triggers on delta < 5%). R4 gives the agent a chance
564
- * to change approach via reject; this rule fires when progress has been exactly
565
- * flat — the agent is making zero forward motion.
566
- *
567
- * v2.7: When enforcement_escalation_enabled is true, the first occurrence
568
- * issues a rejection with an escalation notice instead of terminating.
569
- * Termination only happens on the second consecutive flatline detection.
570
- *
571
- * Uses the engine.max_circuit_breaker policy value as the lookback window. */
572
- function enforceProgressStallTerminal(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
573
- const breakerSize = getPolicy().engine.max_circuit_breaker;
574
- if (currentRound < breakerSize || vaultEntries.length < breakerSize)
575
- return null;
576
- let allFlat = false;
577
- let flatDetail = "";
578
- if (skipEvidenceRules) {
579
- // v3.2: heuristic path has no execution_evidence — fall back to machine
580
- // observations: N consecutive rounds with no git change = flatline.
581
- // No git signal → keep the legacy skip (the machine cannot observe).
582
- const machine = machineProgressSeries(vaultEntries, currentRound, breakerSize);
583
- if (machine === null)
584
- return null;
585
- allFlat = machine.every((value) => !value);
586
- flatDetail = `no machine-observed changes for ${breakerSize} consecutive rounds`;
587
- }
588
- else {
589
- const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
590
- if (progressByRound.size < breakerSize)
591
- return null;
592
- const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
593
- const recent = sortedRounds.slice(-breakerSize);
594
- // v2.14: continuity guard (mirrors R4) — "consecutive" means the most
595
- // recent rounds. Rounds without execution_evidence are unknown motion,
596
- // not zero motion; taking "the last N rounds that happened to report
597
- // progress" terminated loops whose recent rounds simply had no data.
598
- if (recent[0] < currentRound - breakerSize)
599
- return null;
600
- // Check that progress values are ALL identical (delta = 0).
601
- // Use epsilon comparison — JSON round-trips can produce float drift
602
- // (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
603
- const EPSILON = 1e-10;
604
- const first = progressByRound.get(recent[0]);
605
- allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
606
- if (!allFlat || first >= 0.95)
607
- return null;
608
- flatDetail = `completely flat at ${(first * 100).toFixed(0)}%`;
609
- }
610
- if (!allFlat)
611
- return null;
612
- const escalation = getPolicy().engine.enforcement_escalation_enabled;
613
- // v2.7: When escalation is enabled, give one rejection round with a
614
- // "Seek Human Guidance" notice before terminating.
615
- if (consecutiveRejections >= 1) {
616
- return makeEnforcementResult({
617
- action: "terminate",
618
- reason: `Progress has been ${flatDetail} ` +
619
- `for ${breakerSize} consecutive rounds and agent did not resolve ` +
620
- `after escalation. The agent is making zero forward motion.`,
621
- check: "progress_stall_terminal",
622
- });
623
- }
624
- if (escalation) {
625
- // v2.10: When backtrack is enabled, escalate to backtrack instead of
626
- // reject. The agent is rolled back to the last clean round with a
627
- // "radically different strategy" lesson injected.
628
- if (getPolicy().engine.backtrack_enabled) {
629
- // v2.14: Same deadlock guard as R4 — a backtrack that already fired
630
- // for this round means the loop is re-walking rolled-back territory.
631
- if (hasCommittedBacktrack(vaultEntries, currentRound)) {
632
- return makeEnforcementResult({
633
- action: "terminate",
634
- reason: `Progress has been ${flatDetail} ` +
635
- `for ${breakerSize} consecutive rounds and a backtrack already ` +
636
- `fired for this round. Flatline persists after rollback — ` +
637
- `terminating the loop.`,
638
- check: "progress_stall_terminal",
639
- });
640
- }
641
- return makeEnforcementResult({
642
- action: "backtrack",
643
- reason: `Progress has been ${flatDetail} ` +
644
- `for ${breakerSize} consecutive rounds. The agent is making zero ` +
645
- `forward motion.`,
646
- fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
647
- "You are being rolled back to the last clean round. " +
648
- "Choose a RADICALLY different approach. The previous one produced nothing.",
649
- check: "progress_stall_terminal",
650
- });
651
- }
652
- return makeEnforcementResult({
653
- action: "reject",
654
- reason: `Progress has been ${flatDetail} ` +
655
- `for ${breakerSize} consecutive rounds. The agent is making zero ` +
656
- `forward motion.`,
657
- fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
658
- "motion. You must: (a) explain what is fundamentally blocking you, " +
659
- "(b) propose a radically different approach, and " +
660
- "(c) set a concrete verifiable goal. " +
661
- "Do NOT resubmit the same output." +
662
- buildEscalationNotice(),
663
- check: "progress_stall_terminal",
664
- });
665
- }
666
- return makeEnforcementResult({
667
- action: "terminate",
668
- reason: `Progress has been ${flatDetail} ` +
669
- `for ${breakerSize} consecutive rounds. The agent is making zero ` +
670
- `forward motion and cannot recover.`,
671
- check: "progress_stall_terminal",
672
- });
644
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
645
+ const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries);
646
+ return verdict
647
+ ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
648
+ : null;
649
+ }
650
+ function enforceProgressFlatline(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
651
+ const verdict = evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
652
+ return verdict
653
+ ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
654
+ : null;
673
655
  }
674
656
  /** R6: Two consecutive rejections → escalate (v2.7) or terminate.
675
657
  *
@@ -975,7 +957,9 @@ function buildDiagnosticGap(flags) {
975
957
  /** Enforce round-boundary rules based on the verification gate's findings
976
958
  * and the agent's self-evaluation integrity.
977
959
  *
978
- * Rules run in priority order (R1 → R5). The first rule that fires wins.
960
+ * Rules run in priority order — the rules array below IS the priority
961
+ * order; numeric IDs (R1–R9, R-EVID) reflect insertion history, not
962
+ * priority. The first rule that fires wins.
979
963
  *
980
964
  * @param selfEval The agent's self-evaluation for the current round.
981
965
  * @param verifyResult The verification gate's output (from verifySelfEvaluation).
@@ -984,7 +968,7 @@ function buildDiagnosticGap(flags) {
984
968
  * @param consecutiveRejections How many consecutive rounds have already been rejected.
985
969
  * Starts at 0; increments on each reject; resets on accept.
986
970
  */
987
- export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
971
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
988
972
  /** v2.12: Current clarification streak for R7 escalation. */
989
973
  driftClarificationStreak = 0) {
990
974
  const { flags } = verifyResult;
@@ -993,23 +977,77 @@ driftClarificationStreak = 0) {
993
977
  const rules = [
994
978
  () => enforceSuccessWithRemainingCriteria(flags),
995
979
  () => enforceRecurringViolation(flags),
996
- () => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
980
+ () => enforceEmptySuccess(selfEval, flags),
997
981
  // v2.14: R-EVID — a required command failure or self-contradictory
998
982
  // outcome claim contradicts the success claim before commit
999
983
  () => enforceEvidenceContradiction(flags),
984
+ // v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
985
+ // the same round it ran; its result cannot back the success claim.
986
+ // Runs before R8: when the entrypoint is tainted, R8 also fires (the
987
+ // status degrades to unavailable), but this reason is more specific.
988
+ () => enforceVerificationEntrypointTampered(flags),
989
+ // v3.5: contract completion claimed without its verification_plan
990
+ // commands passing — runs before R-C1: the completion-truth question
991
+ // outranks boundary nuance (a completing eval that also lies about its
992
+ // evidence gets the completion reason first).
993
+ () => enforceContractCompletionUnverified(flags, consecutiveRejections),
994
+ // v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
995
+ // wording and check counting; runs before R8 so contract rounds get the
996
+ // contract's own reason. R8 itself is untouched.)
997
+ () => enforcePrematureBoundary(flags, consecutiveRejections),
1000
998
  // v2.12: R8 — success with zero machine-verifiable evidence
1001
999
  () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
1002
- () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
1003
- () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
1000
+ // v3.3: R-C2 — files changed outside the contract's declared scope
1001
+ () => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
1002
+ // v3.3.1: R4 is evaluated first and its stall predicate contains R5's
1003
+ // flatness predicate — under the default ladder R4 proxies for flat runs
1004
+ // too (reject → backtrack/terminate). R5 stays as the reserved severity
1005
+ // tier (see its doc comment: active when progress_stall_threshold <= 0
1006
+ // or after a future window split).
1007
+ () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
1008
+ () => enforceProgressFlatline(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
1004
1009
  () => enforceMaxRejections(consecutiveRejections),
1005
1010
  () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1006
1011
  // v2.12: R9 — post-backtrack workspace not restored
1007
1012
  () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1008
1013
  ];
1014
+ // v3.3.1: rules without an internal escalation ladder (R1/R2/R3/R-EVID/
1015
+ // R-EVID-VERIFY) reject on EVERY occurrence of their check, and the
1016
+ // generic R6 ladder sits AFTER them in this priority array — R6 is only
1017
+ // evaluated when no earlier rule fires, which a persistent offender's
1018
+ // round never is. The documented "same-check rejections accumulate toward
1019
+ // R6" (per-check counter the session maintains) was therefore unreachable
1020
+ // and a repeat offender was rejected forever without a terminating step.
1021
+ // Escalate these rules' rejects on the session's consecutive-rejection
1022
+ // count, mirroring the R8/R7 three-strike rhythm: reject, reject
1023
+ // (escalation notice), terminate on the third consecutive occurrence.
1024
+ // Rules with their own ladder (R4/R5/R6/R7/R8/R9/R-C1/R-C2) are untouched.
1025
+ const UNLADDERED_REJECT_CHECKS = new Set([
1026
+ "success_with_remaining_criteria", // R1
1027
+ "recurring_violation", // R2
1028
+ "empty_success", // R3
1029
+ "required_command_failed", // R-EVID
1030
+ "command_evidence_mismatch", // R-EVID
1031
+ "outcome_success_contradiction", // R-EVID
1032
+ "verification_entrypoint_modified", // R-EVID-VERIFY
1033
+ ]);
1009
1034
  for (const rule of rules) {
1010
1035
  const result = rule();
1011
- if (result)
1012
- return result;
1036
+ if (!result)
1037
+ continue;
1038
+ if (result.action === "reject" &&
1039
+ consecutiveRejections >= 2 &&
1040
+ result.check !== undefined &&
1041
+ UNLADDERED_REJECT_CHECKS.has(result.check)) {
1042
+ return makeEnforcementResult({
1043
+ action: "terminate",
1044
+ reason: `${consecutiveRejections + 1} consecutive rejections for the same ` +
1045
+ `issue (${result.check}) without resolution — the agent cannot ` +
1046
+ `correct it even after escalation.`,
1047
+ check: result.check,
1048
+ });
1049
+ }
1050
+ return result;
1013
1051
  }
1014
1052
  // All rules passed — accept the round
1015
1053
  return makeEnforcementResult({