loopforge 3.2.1 → 3.7.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 (122) hide show
  1. package/README.md +74 -71
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +6 -38
  4. package/dist/audit.js.map +1 -1
  5. package/dist/canonical-state.d.ts +71 -6
  6. package/dist/canonical-state.d.ts.map +1 -1
  7. package/dist/canonical-state.js +176 -29
  8. package/dist/canonical-state.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +16 -26
  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/cognitive-governance.d.ts +0 -1
  17. package/dist/cognitive-governance.d.ts.map +1 -1
  18. package/dist/cognitive-governance.js +1 -4
  19. package/dist/cognitive-governance.js.map +1 -1
  20. package/dist/committed-round.d.ts +100 -0
  21. package/dist/committed-round.d.ts.map +1 -0
  22. package/dist/committed-round.js +349 -0
  23. package/dist/committed-round.js.map +1 -0
  24. package/dist/enforcement-gate.d.ts +4 -2
  25. package/dist/enforcement-gate.d.ts.map +1 -1
  26. package/dist/enforcement-gate.js +385 -357
  27. package/dist/enforcement-gate.js.map +1 -1
  28. package/dist/engine.d.ts +16 -4
  29. package/dist/engine.d.ts.map +1 -1
  30. package/dist/engine.js +65 -16
  31. package/dist/engine.js.map +1 -1
  32. package/dist/evidence-claims.d.ts.map +1 -1
  33. package/dist/evidence-claims.js +15 -39
  34. package/dist/evidence-claims.js.map +1 -1
  35. package/dist/evidence-provider.d.ts +35 -17
  36. package/dist/evidence-provider.d.ts.map +1 -1
  37. package/dist/evidence-provider.js +105 -108
  38. package/dist/evidence-provider.js.map +1 -1
  39. package/dist/index.d.ts +4 -4
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/loop-compiler.d.ts +21 -10
  44. package/dist/loop-compiler.d.ts.map +1 -1
  45. package/dist/loop-compiler.js +232 -276
  46. package/dist/loop-compiler.js.map +1 -1
  47. package/dist/loop-projection.d.ts +5 -25
  48. package/dist/loop-projection.d.ts.map +1 -1
  49. package/dist/loop-projection.js +18 -134
  50. package/dist/loop-projection.js.map +1 -1
  51. package/dist/loop-store.d.ts +16 -18
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +21 -63
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +13 -10
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  57. package/dist/mcp/round-lifecycle.js +95 -68
  58. package/dist/mcp/round-lifecycle.js.map +1 -1
  59. package/dist/mcp/server.js +3 -3
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/mcp/session.d.ts +17 -5
  62. package/dist/mcp/session.d.ts.map +1 -1
  63. package/dist/mcp/session.js +177 -54
  64. package/dist/mcp/session.js.map +1 -1
  65. package/dist/mcp/tools.d.ts +12 -0
  66. package/dist/mcp/tools.d.ts.map +1 -1
  67. package/dist/mcp/tools.js +161 -53
  68. package/dist/mcp/tools.js.map +1 -1
  69. package/dist/policy-metrics.d.ts +0 -7
  70. package/dist/policy-metrics.d.ts.map +1 -1
  71. package/dist/policy-metrics.js +15 -55
  72. package/dist/policy-metrics.js.map +1 -1
  73. package/dist/policy.d.ts +22 -31
  74. package/dist/policy.d.ts.map +1 -1
  75. package/dist/policy.js +24 -42
  76. package/dist/policy.js.map +1 -1
  77. package/dist/prompt-assembler.d.ts +0 -3
  78. package/dist/prompt-assembler.d.ts.map +1 -1
  79. package/dist/prompt-assembler.js +124 -191
  80. package/dist/prompt-assembler.js.map +1 -1
  81. package/dist/protocol.d.ts +61 -46
  82. package/dist/protocol.d.ts.map +1 -1
  83. package/dist/protocol.js +2 -18
  84. package/dist/protocol.js.map +1 -1
  85. package/dist/replay.d.ts.map +1 -1
  86. package/dist/replay.js +21 -22
  87. package/dist/replay.js.map +1 -1
  88. package/dist/round-contract.d.ts +64 -0
  89. package/dist/round-contract.d.ts.map +1 -0
  90. package/dist/round-contract.js +80 -0
  91. package/dist/round-contract.js.map +1 -0
  92. package/dist/round-coordinator.d.ts +5 -3
  93. package/dist/round-coordinator.d.ts.map +1 -1
  94. package/dist/round-coordinator.js +34 -29
  95. package/dist/round-coordinator.js.map +1 -1
  96. package/dist/round-driver.d.ts +0 -4
  97. package/dist/round-driver.d.ts.map +1 -1
  98. package/dist/round-driver.js +1 -11
  99. package/dist/round-driver.js.map +1 -1
  100. package/dist/round-transaction.d.ts +0 -1
  101. package/dist/round-transaction.d.ts.map +1 -1
  102. package/dist/round-transaction.js +0 -1
  103. package/dist/round-transaction.js.map +1 -1
  104. package/dist/self-eval.d.ts +29 -34
  105. package/dist/self-eval.d.ts.map +1 -1
  106. package/dist/self-eval.js +112 -148
  107. package/dist/self-eval.js.map +1 -1
  108. package/dist/token-utils.d.ts +7 -3
  109. package/dist/token-utils.d.ts.map +1 -1
  110. package/dist/token-utils.js +18 -3
  111. package/dist/token-utils.js.map +1 -1
  112. package/dist/verification-gate.d.ts +72 -21
  113. package/dist/verification-gate.d.ts.map +1 -1
  114. package/dist/verification-gate.js +544 -157
  115. package/dist/verification-gate.js.map +1 -1
  116. package/dist/workspace.d.ts +29 -0
  117. package/dist/workspace.d.ts.map +1 -0
  118. package/dist/workspace.js +61 -0
  119. package/dist/workspace.js.map +1 -0
  120. package/loop_policy.json +5 -17
  121. package/package.json +2 -2
  122. package/skills/perception/SKILL.md +137 -136
@@ -17,35 +17,22 @@
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";
21
- import { effectiveSuccess } from "./self-eval.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 } from "./verification-gate.js";
22
21
  import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
23
22
  import { getPolicy } from "./policy.js";
24
- import { STABLE_ID_RE, isRecord } from "./token-utils.js";
25
- import { isProcessResult } from "./round-transaction.js";
23
+ import { STABLE_ID_RE, isRecord, entryRound, extractFilePathTokens } from "./token-utils.js";
24
+ import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
26
25
  // ═══════════════════════════════════════════════════════════════════════════
27
26
  // Helpers
28
27
  // ═══════════════════════════════════════════════════════════════════════════
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
28
  /** Collect progress estimates from vault entries into a round→estimate map.
39
- * Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
29
+ * Shared by the progress evaluator's stall and flatline tiers. */
40
30
  function collectProgressByRound(vaultEntries, currentRound) {
41
31
  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);
32
+ for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
33
+ const progress = round.executionEvidence?.progress_estimate;
34
+ if (typeof progress === "number")
35
+ progressByRound.set(round.round, progress);
49
36
  }
50
37
  return progressByRound;
51
38
  }
@@ -73,13 +60,7 @@ function progressWindow(vaultEntries, currentRound, selfEval) {
73
60
  * decision under loop_lineage.round_transaction; lineage entries carry
74
61
  * nothing at the top level. */
75
62
  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;
63
+ return decodeCommittedRound(entry)?.result ?? null;
83
64
  }
84
65
  /** True when a backtrack already committed at or after the current round —
85
66
  * i.e. the loop is re-walking territory the system already rolled back
@@ -91,7 +72,7 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
91
72
  return committedRoundResult(entry)?.action === "backtrack";
92
73
  });
93
74
  }
94
- /** Check whether a vault entry represents a "clean" round that can serve
75
+ /** Check whether a committed round represents a clean restore point.
95
76
  * as a safe restore point. A clean round was accepted (not rejected)
96
77
  * and had no error-level verification flags.
97
78
  *
@@ -101,61 +82,30 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
101
82
  * valid backtrack target (the most common backtrack trigger is exactly a
102
83
  * run of such rounds). Rejected/terminated rounds never commit, and
103
84
  * 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;
85
+ function isCleanRound(round) {
86
+ return !round.verificationFlags.some((flag) => flag.severity === "error");
124
87
  }
125
88
  /** Scan backwards from currentRound to find the most recent clean round.
126
89
  * Collects discovered_constraints from skipped rounds along the way.
127
90
  * Returns null when no clean round is found within maxDepth. */
128
91
  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
92
+ const completed = committedRoundsFromEntries(vaultEntries, currentRound)
93
+ .sort((a, b) => b.round - a.round);
142
94
  const skippedDiscoveries = [];
143
95
  for (let depth = 1; depth <= maxDepth; depth++) {
144
96
  const targetRound = currentRound - depth;
145
97
  if (targetRound < 1)
146
98
  break;
147
- const entry = completed.find((e) => entryRound(e) === targetRound);
148
- if (!entry)
99
+ const round = completed.find((item) => item.round === targetRound);
100
+ if (!round)
149
101
  continue;
150
102
  // Collect discoveries from rounds we're about to skip (depth > 1)
151
103
  if (depth > 1) {
152
104
  for (let d = 1; d < depth; d++) {
153
105
  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
- : [];
106
+ const skippedEntry = completed.find((item) => item.round === skippedRound);
107
+ if (skippedEntry?.evaluation) {
108
+ const discovered = skippedEntry.evaluation.discovered_constraints ?? [];
159
109
  for (const item of discovered) {
160
110
  if (!skippedDiscoveries.includes(item))
161
111
  skippedDiscoveries.push(item);
@@ -163,7 +113,7 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
163
113
  }
164
114
  }
165
115
  }
166
- if (isCleanRound(entry)) {
116
+ if (isCleanRound(round)) {
167
117
  return { round: targetRound, skippedDiscoveries };
168
118
  }
169
119
  }
@@ -174,23 +124,11 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
174
124
  * evidence as fallback). Returns null when the round has no git snapshot
175
125
  * or no committed feedback entry. */
176
126
  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)
127
+ const committed = committedRoundsFromEntries(vaultEntries)
128
+ .find((view) => view.round === restoreRound);
129
+ if (!committed)
188
130
  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
- : [];
131
+ const evidence = machineEvidenceForRound(committed);
194
132
  for (const item of evidence) {
195
133
  if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
196
134
  continue;
@@ -217,14 +155,15 @@ gitHead) {
217
155
  "",
218
156
  ];
219
157
  // ── Why This Happened ────────────────────────────────────────────────
158
+ // v3.7: stall and flatline are one evaluator emitting progress_stall; the
159
+ // former flatline-specific wording ("zero forward motion → radically
160
+ // different strategy") is folded into the single section — the disposition
161
+ // tier still distinguishes the enforcement reason itself.
220
162
  if (triggerRule === "progress_stall") {
221
- lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
222
- `The work done in those rounds produced no verifiable progress — ` +
223
- `no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
224
- }
225
- else if (triggerRule === "progress_stall_terminal") {
226
- lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
227
- `The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
163
+ lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}: the work ` +
164
+ `produced no verifiable forward motion — no new files changed, no ` +
165
+ `criteria met, no progress estimates moving.`, "", "The approach used in those rounds **did not work**. It should not be " +
166
+ "repeated — a **radically different** strategy is needed.", "");
228
167
  }
229
168
  // ── v2.13: Workspace Restore ──────────────────────────────────────────
230
169
  lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
@@ -241,7 +180,10 @@ gitHead) {
241
180
  }
242
181
  lines.push("");
243
182
  }
244
- lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "");
183
+ 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 " +
184
+ "the next round: the skipped work would look unrestored and be rejected. " +
185
+ "Restore only files you own after the next round commits, via " +
186
+ "`git checkout stash@{0} -- <path>`.", "");
245
187
  // v2.12: Restore to the exact commit observed at the clean round.
246
188
  if (gitHead) {
247
189
  lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
@@ -255,7 +197,16 @@ gitHead) {
255
197
  "> with unrestored files will be **rejected**.", "");
256
198
  // ── What Must Change ──────────────────────────────────────────────────
257
199
  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 " +
200
+ "(`canceled_subtasks`) and working on a different one.",
201
+ // v3.5: the restored Current Task may be the very Round Contract that
202
+ // stalled — the agent must be told the sanctioned way to revise it
203
+ // (outcome=blocked closes the active contract; the revised proposal in
204
+ // the same submission becomes active next round). Without this the
205
+ // agent's revision would be silently ignored as premature.
206
+ "- If the restored Current Task is a Round Contract that caused the stall: " +
207
+ "close it in this submission with `outcome=\"blocked\"` (+ a blocker " +
208
+ "explaining why it stalled) and declare the REVISED contract in the " +
209
+ "same submission. Do **NOT** silently restate the stalled contract.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
259
210
  "to flag anything you don't understand about the task or the blockage.", "");
260
211
  // ── Preserved Discoveries ─────────────────────────────────────────────
261
212
  if (skippedDiscoveries.length > 0) {
@@ -333,53 +284,34 @@ function enforceEvidenceContradiction(flags) {
333
284
  check: contradiction.check,
334
285
  });
335
286
  }
336
- /** R3: Agent claims success but did nothing verifiable.
337
- * v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
338
- * Missing evidence when success=true → reject (agent must provide evidence).
339
- * 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
343
- if (!effectiveSuccess(selfEval))
344
- return null;
345
- const ev = selfEval.execution_evidence;
346
- // v1.17: Missing evidence when claiming success → reject.
347
- // The agent MUST provide execution_evidence to back up a success claim.
348
- if (!ev) {
349
- return makeEnforcementResult({
350
- action: "reject",
351
- reason: "Agent claims success but provided no execution_evidence. " +
352
- "Every successful round MUST include execution_evidence with " +
353
- "files_changed, test_results, and progress_estimate.",
354
- fix_instructions: "You must provide execution_evidence in your self-evaluation: " +
355
- "(a) list the files you changed in execution_evidence.files_changed, " +
356
- "(b) run tests and report results in execution_evidence.test_results, " +
357
- "(c) estimate your progress in execution_evidence.progress_estimate. " +
358
- "If you genuinely completed the task without file changes or tests, " +
359
- "explain why in detail in your output_summary.",
360
- check: "empty_success",
361
- });
362
- }
363
- const filesEmpty = ev.files_changed.length === 0;
364
- const testsNotRun = ev.test_results === null;
365
- if (!filesEmpty || !testsNotRun)
287
+ /** v3.3: R-EVID-VERIFY — the verification command's entrypoint changed in
288
+ * the same round the command ran. The runtime executed a script the agent
289
+ * just rewrote, so the "machine observation" has no stable baseline and
290
+ * cannot back the success claim. Reject so the agent resubmits the SAME
291
+ * round with the entrypoint unchanged (a freshly created entrypoint passes
292
+ * on the redo — the diff no longer contains it). */
293
+ function enforceVerificationEntrypointTampered(flags) {
294
+ const flag = flags.find((f) => f.check === CHECK_VERIFICATION_ENTRYPOINT_MODIFIED && f.severity === "error");
295
+ if (!flag)
366
296
  return null;
367
297
  return makeEnforcementResult({
368
298
  action: "reject",
369
- reason: "Agent claims success but execution_evidence shows no files changed " +
370
- "and no tests were run. There is no verifiable evidence of work.",
371
- fix_instructions: "You must provide verifiable evidence: " +
372
- "(a) list the files you changed in execution_evidence.files_changed, " +
373
- "and (b) run tests and report results in execution_evidence.test_results. " +
374
- "If you genuinely completed the task without file changes or tests, " +
375
- "explain why in detail in your output_summary.",
376
- check: "empty_success",
299
+ reason: flag.detail,
300
+ fix_instructions: "The verification command entrypoint changed in this round, so its " +
301
+ "result cannot be trusted as machine evidence. You must: (a) keep the " +
302
+ "verification command (and its entrypoint files) stable, (b) resubmit " +
303
+ "your SelfEvaluation for the SAME round with the entrypoint unchanged " +
304
+ "so the runtime can re-run it against a stable baseline, and (c) do " +
305
+ "NOT rewrite the verification script to make it pass.",
306
+ check: "verification_entrypoint_modified",
377
307
  });
378
308
  }
379
- /** R8 (v2.12): Success claimed without any machine-verifiable evidence.
380
- * The verification gate produced success_without_verified_evidence (error):
381
- * zero verified claims, no test results. First occurrence → reject with
382
- * concrete evidence requirements; second consecutive → terminate. */
309
+ /** R8 (v2.12/v3.7): The single success-evidence row. The verification gate
310
+ * produced success_without_verified_evidence (error) — either the empty/
311
+ * missing-evidence arm (v3.7: the ex-R3 empty_success posture moved into
312
+ * checkSuccessWithoutVerifiedEvidence) or the claims arm (zero verified
313
+ * claims). First occurrence → reject with concrete evidence requirements;
314
+ * second consecutive → terminate. */
383
315
  function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
384
316
  const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
385
317
  if (!flag)
@@ -407,6 +339,131 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
407
339
  check: "success_without_verified_evidence",
408
340
  });
409
341
  }
342
+ /** R-C1 (v3.3): Round Contract boundary claimed prematurely. The gate
343
+ * produced premature_boundary (error): success claimed while done_when
344
+ * items were claimed met without machine-verified evidence or silently
345
+ * dropped. First occurrence → reject with contract-specific fix
346
+ * instructions; second consecutive → terminate (R8 ladder). */
347
+ function enforcePrematureBoundary(flags, consecutiveRejections) {
348
+ const flag = flags.find((f) => f.check === CHECK_PREMATURE_BOUNDARY && f.severity === "error");
349
+ if (!flag)
350
+ return null;
351
+ if (consecutiveRejections >= 2) {
352
+ return makeEnforcementResult({
353
+ action: "terminate",
354
+ reason: "Repeated premature Round Contract boundary claims — done_when items " +
355
+ "claimed without machine evidence or dropped while success is claimed.",
356
+ check: "premature_boundary",
357
+ });
358
+ }
359
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
360
+ consecutiveRejections >= 1
361
+ ? buildEscalationNotice()
362
+ : "";
363
+ return makeEnforcementResult({
364
+ action: "reject",
365
+ reason: flag.detail,
366
+ fix_instructions: "You claimed success under a Round Contract but done_when items are " +
367
+ "unfinished or unverified. You must: (a) run the contract's " +
368
+ "verification_plan commands (configured in loop_policy.json " +
369
+ "evidence.commands) and report their actual output, listing satisfied " +
370
+ "done_when items in success_criteria_met; or (b) set success=false and " +
371
+ "list what remains in success_criteria_remaining. " +
372
+ "Do NOT claim contract completion without machine evidence " +
373
+ "(no_change_reason does not apply to contract claims)." + escalation,
374
+ check: "premature_boundary",
375
+ });
376
+ }
377
+ /** v3.5: Round Contract completion claimed without its verification_plan
378
+ * commands passing this round. The gate produced
379
+ * contract_completion_unverified (error): the eval's met claims satisfy
380
+ * every done_when of the ACTIVE contract, but the machine never observed
381
+ * the plan's commands pass. Closing a contract is a success-class claim —
382
+ * first occurrence → reject with fix instructions; second consecutive →
383
+ * terminate (R-C1's ladder). Registered BEFORE R-C1: the completion-truth
384
+ * question outranks boundary nuance. */
385
+ function enforceContractCompletionUnverified(flags, consecutiveRejections) {
386
+ const flag = flags.find((f) => f.check === CHECK_CONTRACT_COMPLETION_UNVERIFIED && f.severity === "error");
387
+ if (!flag)
388
+ return null;
389
+ if (consecutiveRejections >= 2) {
390
+ return makeEnforcementResult({
391
+ action: "terminate",
392
+ reason: "Repeated unverified Round Contract completion claims — done_when items " +
393
+ "claimed complete while the verification_plan commands never passed.",
394
+ check: "contract_completion_unverified",
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 completion under the ACTIVE Round Contract, but its " +
405
+ "verification_plan commands did not pass this round. You must: (a) fix " +
406
+ "the underlying failure, re-run the contract's verification_plan " +
407
+ "commands (configured in loop_policy.json evidence.commands), and list " +
408
+ "satisfied done_when items in success_criteria_met; or (b) set " +
409
+ "success=false and list what remains in success_criteria_remaining. " +
410
+ "Completion claims are never accepted without machine verification — " +
411
+ "do NOT use no_change_reason for completion claims." + escalation,
412
+ check: "contract_completion_unverified",
413
+ });
414
+ }
415
+ /** R-C2 (v3.3): Round Contract scope drift. The gate produced
416
+ * round_scope_drift (warn): files changed outside the contract's declared
417
+ * scope. A substantive drift_clarification (≥ 20 chars with real anchors —
418
+ * the out-of-scope file paths count) accepts the legitimately expanded
419
+ * scope; no/weak clarification rejects; repeated unclarified drift
420
+ * terminates. Mirrors R8's ladder, not R7's streak counter — no new
421
+ * session state. */
422
+ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries = []) {
423
+ const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
424
+ if (!flag)
425
+ return null;
426
+ const clarification = selfEval.drift_clarification?.trim();
427
+ const hasAnchor = clarification
428
+ ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
429
+ : false;
430
+ const isSubstantive = clarification !== undefined
431
+ && clarification.length >= 20
432
+ && hasAnchor;
433
+ if (isSubstantive) {
434
+ // Genuine scope expansion with concrete file references → accept.
435
+ return makeEnforcementResult({
436
+ action: "accept",
437
+ reason: "",
438
+ fix_instructions: "",
439
+ check: "round_scope_drift",
440
+ clarification_accepted: true,
441
+ });
442
+ }
443
+ if (consecutiveRejections >= 2) {
444
+ return makeEnforcementResult({
445
+ action: "terminate",
446
+ reason: `Repeated scope drift without substantive drift_clarification — ` +
447
+ `the agent keeps changing files outside its declared contract scope.`,
448
+ check: "round_scope_drift",
449
+ });
450
+ }
451
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
452
+ consecutiveRejections >= 1
453
+ ? buildEscalationNotice()
454
+ : "";
455
+ return makeEnforcementResult({
456
+ action: "reject",
457
+ reason: flag.detail,
458
+ fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
459
+ "(a) revert the out-of-scope changes, or (b) extend the scope in your " +
460
+ "re-declared round_contract AND explain the pivot in drift_clarification " +
461
+ "with concrete file paths (≥ 20 characters, real anchors). Weak or " +
462
+ "missing clarifications are rejected; repeated scope drift terminates " +
463
+ "the loop." + escalation,
464
+ check: "round_scope_drift",
465
+ });
466
+ }
410
467
  /** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
411
468
  * produced backtrack_workspace_not_restored (error): either skipped-round
412
469
  * files are still dirty, or the working tree is not at the restore point's
@@ -439,237 +496,132 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
439
496
  check: "backtrack_workspace_not_restored",
440
497
  });
441
498
  }
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)
499
+ /** Shared observation pipeline for the progress evaluator (stall window,
500
+ * flatline tier). Machine git motion is exculpatory only: it may veto a
501
+ * self-reported stall but can never create one. */
502
+ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
503
+ const window = tier === "progress_stall"
504
+ ? 3
505
+ : Math.max(1, getPolicy().engine.stall_lookback_rounds);
506
+ if (currentRound < window)
449
507
  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)
508
+ const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
509
+ let stalled = false;
510
+ let detail = "";
511
+ const threshold = getPolicy().evolution.progress_stall_threshold;
512
+ if (progressByRound.size >= window) {
513
+ const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
514
+ if (recent[0] < currentRound - window)
515
+ return null;
516
+ const values = recent.map((round) => progressByRound.get(round));
517
+ if (values[values.length - 1] >= 0.95)
459
518
  return null;
460
- isStalling = machine.every((value) => !value);
461
- stallDetail = "no machine-observed changes in the last 3 rounds";
519
+ if (tier === "progress_stall") {
520
+ stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
521
+ detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ");
522
+ }
523
+ else {
524
+ const first = values[0];
525
+ stalled = values.every((value) => Math.abs(value - first) < 1e-10);
526
+ detail = `completely flat at ${(first * 100).toFixed(0)}%`;
527
+ }
462
528
  }
463
529
  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)
530
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
531
+ if (machine === null)
474
532
  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)}%`;
533
+ stalled = machine.every((value) => !value);
534
+ detail = tier === "progress_stall"
535
+ ? `no machine-observed changes in the last ${window} rounds`
536
+ : `no machine-observed changes for ${window} consecutive rounds`;
484
537
  }
485
- if (!isStalling)
538
+ if (!stalled)
486
539
  return null;
540
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
541
+ if (machine?.some(Boolean))
542
+ return null;
543
+ if (machine !== null) {
544
+ detail += ` — no machine-observed git motion in rounds ${currentRound - window}–${currentRound - 1}`;
545
+ }
546
+ return { tier, detail, window, threshold };
547
+ }
548
+ /** Shared progress escalation and backtrack deadlock guard (v3.7: one
549
+ * evaluator). The flatline tier keeps its steeper threshold-0 ladder while
550
+ * using the same mechanics — the tier is an internal diagnostic only. */
551
+ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections) {
552
+ const { tier, detail, window, threshold } = verdict;
553
+ const flatline = tier === "progress_flatline";
487
554
  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) {
555
+ const stalledReason = flatline
556
+ ? `Progress has been ${detail} for ${window} consecutive rounds.`
557
+ : `Progress has stalled: ${detail} over the last ${window} rounds` +
558
+ `${threshold > 0 ? ` (delta < ${(threshold * 100).toFixed(0)}% each round)` : ""}.`;
559
+ // v3.7: both tiers emit the single progress_stall check id — the tier
560
+ // survives only in the reason/fix wording (former progress_flatline id).
561
+ const check = "progress_stall";
562
+ if ((flatline && consecutiveRejections >= 1) || consecutiveRejections >= 2) {
492
563
  return makeEnforcementResult({
493
564
  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",
565
+ reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
566
+ check,
498
567
  });
499
568
  }
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
- }
569
+ if (consecutiveRejections >= 1 && !escalation) {
570
+ return makeEnforcementResult({
571
+ action: "terminate",
572
+ reason: `${stalledReason} The stall persisted after the previous rejection. Terminating loop.`,
573
+ check,
574
+ });
575
+ }
576
+ const escalated = flatline ? escalation : consecutiveRejections >= 1 && escalation;
577
+ if (escalated && getPolicy().engine.backtrack_enabled) {
578
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
518
579
  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",
580
+ action: "terminate",
581
+ reason: `${stalledReason} A backtrack already fired for this round; the stall persists after rollback.`,
582
+ check,
526
583
  });
527
584
  }
528
585
  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",
586
+ action: "backtrack",
587
+ reason: stalledReason,
588
+ fix_instructions: flatline
589
+ ? "Progress is exactly flat. After rollback, choose a radically different approach."
590
+ : "After rollback, do not repeat the stalled approach; choose a different technique or task decomposition.",
591
+ check,
539
592
  });
540
593
  }
541
- if (consecutiveRejections >= 1) {
594
+ if (flatline && !escalation) {
542
595
  return makeEnforcementResult({
543
596
  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",
597
+ reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
598
+ check,
548
599
  });
549
600
  }
550
601
  return makeEnforcementResult({
551
602
  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",
603
+ reason: stalledReason,
604
+ fix_instructions: (flatline
605
+ ? "Explain the fundamental blocker, choose a radically different approach, and set a concrete verifiable goal."
606
+ : "Explain the blocker, choose a different technique or task decomposition, and set a concrete verifiable goal for this retry.") +
607
+ (escalated ? buildEscalationNotice() : ""),
608
+ check,
560
609
  });
561
610
  }
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
- });
611
+ /** v3.7: The single progress evaluator (former R4 stall and R5 flatline rows
612
+ * merged into one). The stall-tier window is consulted first — under the
613
+ * default positive threshold its predicate covers exactly-flat runs too,
614
+ * reproducing the v3.6 R4-before-R5 shadowing; the flatline tier is
615
+ * consulted only when the stall predicate cannot hold (threshold ≤ 0,
616
+ * shorter lookback configs), where it applies its steeper disposition
617
+ * ladder. Machine git motion is exculpatory only — it may veto a
618
+ * self-reported stall but can never create one. */
619
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
620
+ const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries)
621
+ ?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
622
+ return verdict
623
+ ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
624
+ : null;
673
625
  }
674
626
  /** R6: Two consecutive rejections → escalate (v2.7) or terminate.
675
627
  *
@@ -725,8 +677,8 @@ function extractAnchors(clarification) {
725
677
  for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
726
678
  anchors.add(match[0].toLowerCase());
727
679
  }
728
- for (const match of clarification.matchAll(/[\w./-]+\.[a-z]{2,6}\b/gi)) {
729
- anchors.add(match[0]);
680
+ for (const path of extractFilePathTokens(clarification)) {
681
+ anchors.add(path);
730
682
  }
731
683
  return [...anchors];
732
684
  }
@@ -969,13 +921,38 @@ function buildDiagnosticGap(flags) {
969
921
  lines.push("");
970
922
  return lines.join("\n");
971
923
  }
924
+ const RULE_TABLE = [
925
+ // R1/R2 join the contradiction umbrella per the documented four-class model
926
+ // (AGENTS/README/CHANGELOG): a row's category is its enforcement action
927
+ // class, NOT the trigger check's gate domain — claim-vs-self-reported-facts
928
+ // rows (outcome_success_contradiction included) belong to this class too.
929
+ { category: "evidence_contradiction", checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
930
+ { category: "evidence_contradiction", checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
931
+ { category: "evidence_contradiction", checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
932
+ { category: "evidence_contradiction", checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
933
+ { category: "contract_scope", checks: ["contract_completion_unverified"], ladder: "internal" },
934
+ { category: "contract_scope", checks: ["premature_boundary"], ladder: "internal" },
935
+ { category: "evidence_contradiction", checks: ["success_without_verified_evidence"], ladder: "internal" },
936
+ { category: "contract_scope", checks: ["round_scope_drift"], ladder: "internal", clarificationAllowed: true },
937
+ { category: "progress_recovery", checks: ["progress_stall"], ladder: "internal", backtrackAllowed: true },
938
+ { category: "progress_recovery", checks: ["max_rejections"], ladder: "counter" },
939
+ { category: "plan_drift", checks: ["intent_drift"], ladder: "internal", clarificationAllowed: true },
940
+ { category: "progress_recovery", checks: ["backtrack_workspace_not_restored"], ladder: "internal", backtrackAllowed: true },
941
+ ];
942
+ const RULE_TABLE_BY_ID = new Map();
943
+ for (const row of RULE_TABLE) {
944
+ for (const id of row.checks)
945
+ RULE_TABLE_BY_ID.set(id, row);
946
+ }
972
947
  // ═══════════════════════════════════════════════════════════════════════════
973
948
  // Main entry point
974
949
  // ═══════════════════════════════════════════════════════════════════════════
975
950
  /** Enforce round-boundary rules based on the verification gate's findings
976
951
  * and the agent's self-evaluation integrity.
977
952
  *
978
- * Rules run in priority order (R1 → R5). The first rule that fires wins.
953
+ * Rules run in priority order — the rules array below IS the priority
954
+ * order; numeric IDs (R1–R9, R-EVID) reflect insertion history, not
955
+ * priority. The first rule that fires wins.
979
956
  *
980
957
  * @param selfEval The agent's self-evaluation for the current round.
981
958
  * @param verifyResult The verification gate's output (from verifySelfEvaluation).
@@ -984,32 +961,83 @@ function buildDiagnosticGap(flags) {
984
961
  * @param consecutiveRejections How many consecutive rounds have already been rejected.
985
962
  * Starts at 0; increments on each reject; resets on accept.
986
963
  */
987
- export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
964
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
988
965
  /** v2.12: Current clarification streak for R7 escalation. */
989
966
  driftClarificationStreak = 0) {
990
967
  const { flags } = verifyResult;
991
- // Run enforcement rules in priority order.
992
- // Earlier rules take higher precedence.
968
+ // Run enforcement rules in priority order — the array order IS the
969
+ // priority; the R-labels in the comments are historical (RULE_TABLE is
970
+ // the semantic description). The first rule that fires wins.
993
971
  const rules = [
994
972
  () => enforceSuccessWithRemainingCriteria(flags),
995
973
  () => enforceRecurringViolation(flags),
996
- () => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
997
- // v2.14: R-EVID — a required command failure or self-contradictory
998
- // outcome claim contradicts the success claim before commit
974
+ // v2.14: R-EVID — a required command failure, hidden test failures, or
975
+ // a self-contradictory outcome claim contradicts the success claim
999
976
  () => enforceEvidenceContradiction(flags),
1000
- // v2.12: R8 — success with zero machine-verifiable evidence
977
+ // v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
978
+ // the same round it ran. Runs before R8: when the entrypoint is tainted
979
+ // R8 also fires, but this reason is more specific.
980
+ () => enforceVerificationEntrypointTampered(flags),
981
+ // v3.5: contract completion claimed without its verification_plan
982
+ // commands passing — runs before R-C1: the completion-truth question
983
+ // outranks boundary nuance.
984
+ () => enforceContractCompletionUnverified(flags, consecutiveRejections),
985
+ // v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
986
+ // wording and check counting; runs before R8 so contract rounds get the
987
+ // contract's own reason.)
988
+ () => enforcePrematureBoundary(flags, consecutiveRejections),
989
+ // v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
990
+ // arm merged from the former R3 empty_success posture + claims arm)
1001
991
  () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
1002
- () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
1003
- () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
992
+ // v3.3: R-C2 — files changed outside the contract's declared scope
993
+ () => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
994
+ // v3.7: single progress evaluator (former R4/R5 slots merged) —
995
+ // reject → backtrack → terminate with the deadlock guard
996
+ () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
997
+ // R6: rejection-counter catch-all (evaluated only when no row above
998
+ // fired — a clean-looking round carrying a high persisted counter)
1004
999
  () => enforceMaxRejections(consecutiveRejections),
1000
+ // v2.12: R7 — intent drift; its weak-clarification streak is a separate
1001
+ // counter and never mixes with the global rejection count
1005
1002
  () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1006
1003
  // v2.12: R9 — post-backtrack workspace not restored
1007
1004
  () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1008
1005
  ];
1006
+ // v3.7: uniform-ladder escalation. Rows without their own escalation
1007
+ // ladder (R1/R2/R-EVID/R-EVID-VERIFY) escalate on the session's
1008
+ // consecutive-rejection count: repeat rejections carry the escalation
1009
+ // notice (when enabled — the pre-v3.7 comment promised this notice but the
1010
+ // UNLADDERED name-set patch never appended it), and a third consecutive
1011
+ // occurrence terminates. This replaces the UNLADDERED_REJECT_CHECKS patch,
1012
+ // which existed because the generic R6 ladder sits AFTER these rows and a
1013
+ // persistent offender's round never reached it. Rows with their own ladder
1014
+ // (RULE_TABLE.ladder === "internal" / "counter") are untouched.
1009
1015
  for (const rule of rules) {
1010
- const result = rule();
1011
- if (result)
1012
- return result;
1016
+ let result = rule();
1017
+ if (!result)
1018
+ continue;
1019
+ const row = result.check !== undefined
1020
+ ? RULE_TABLE_BY_ID.get(result.check)
1021
+ : undefined;
1022
+ if (result.action === "reject" && row && row.ladder === "uniform") {
1023
+ if (consecutiveRejections >= (row.terminalAfter ?? 2)) {
1024
+ return makeEnforcementResult({
1025
+ action: "terminate",
1026
+ reason: `${consecutiveRejections + 1} consecutive rejections for the same ` +
1027
+ `issue (${result.check}) without resolution — the agent cannot ` +
1028
+ `correct it even after escalation.`,
1029
+ check: result.check,
1030
+ });
1031
+ }
1032
+ if (consecutiveRejections >= 1 && row.noticeOnRepeat &&
1033
+ getPolicy().engine.enforcement_escalation_enabled) {
1034
+ result = makeEnforcementResult({
1035
+ ...result,
1036
+ fix_instructions: result.fix_instructions + buildEscalationNotice(),
1037
+ });
1038
+ }
1039
+ }
1040
+ return result;
1013
1041
  }
1014
1042
  // All rules passed — accept the round
1015
1043
  return makeEnforcementResult({