loopforge 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
@@ -17,11 +17,10 @@
17
17
  * with stopReason "enforcement_terminated".
18
18
  */
19
19
  import { makeEnforcementResult } from "./protocol.js";
20
- import { machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_PREMATURE_BOUNDARY, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_COMPLETION_UNVERIFIED } from "./verification-gate.js";
21
- import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
20
+ import { machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_ITEMS_UNVERIFIED, CHECK_USER_GATE_UNRESOLVED } from "./verification-gate.js";
22
21
  import { getPolicy } from "./policy.js";
23
- import { STABLE_ID_RE, isRecord, entryRound, extractFilePathTokens } from "./token-utils.js";
24
- import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
22
+ import { isRecord, entryRound } from "./token-utils.js";
23
+ import { derivationRounds, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
25
24
  // ═══════════════════════════════════════════════════════════════════════════
26
25
  // Helpers
27
26
  // ═══════════════════════════════════════════════════════════════════════════
@@ -29,8 +28,8 @@ import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRou
29
28
  * Shared by the progress evaluator's stall and flatline tiers. */
30
29
  function collectProgressByRound(vaultEntries, currentRound) {
31
30
  const progressByRound = new Map();
32
- for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
33
- const progress = round.executionEvidence?.progress_estimate;
31
+ for (const round of derivationRounds(vaultEntries, currentRound)) {
32
+ const progress = round.executionReport?.progress_estimate;
34
33
  if (typeof progress === "number")
35
34
  progressByRound.set(round.round, progress);
36
35
  }
@@ -46,7 +45,7 @@ function collectProgressByRound(vaultEntries, currentRound) {
46
45
  function progressWindow(vaultEntries, currentRound, selfEval) {
47
46
  const progressByRound = collectProgressByRound(vaultEntries, currentRound);
48
47
  if (hasCommittedBacktrack(vaultEntries, currentRound)) {
49
- const pe = selfEval.execution_evidence?.progress_estimate;
48
+ const pe = selfEval.execution_report?.progress_estimate;
50
49
  if (typeof pe === "number")
51
50
  progressByRound.set(currentRound, pe);
52
51
  }
@@ -89,7 +88,7 @@ function isCleanRound(round) {
89
88
  * Collects discovered_constraints from skipped rounds along the way.
90
89
  * Returns null when no clean round is found within maxDepth. */
91
90
  export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
92
- const completed = committedRoundsFromEntries(vaultEntries, currentRound)
91
+ const completed = derivationRounds(vaultEntries, currentRound)
93
92
  .sort((a, b) => b.round - a.round);
94
93
  const skippedDiscoveries = [];
95
94
  for (let depth = 1; depth <= maxDepth; depth++) {
@@ -124,13 +123,13 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
124
123
  * evidence as fallback). Returns null when the round has no git snapshot
125
124
  * or no committed feedback entry. */
126
125
  export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
127
- const committed = committedRoundsFromEntries(vaultEntries)
126
+ const committed = derivationRounds(vaultEntries)
128
127
  .find((view) => view.round === restoreRound);
129
128
  if (!committed)
130
129
  return null;
131
130
  const evidence = machineEvidenceForRound(committed);
132
131
  for (const item of evidence) {
133
- if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
132
+ if (!isRecord(item) || item.providerId !== "git" || !isRecord(item.data))
134
133
  continue;
135
134
  const head = item.data.head;
136
135
  if (typeof head === "string" && head.length > 0)
@@ -138,22 +137,77 @@ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
138
137
  }
139
138
  return null;
140
139
  }
141
- /** Build the backtrack prompt injected at the top of the restored round.
140
+ /** v3.8.1: the ONE Recovery Brief renderer.
142
141
  *
143
- * v2.13: Includes concrete workspace restore instructions with affected
144
- * file lists from skipped rounds. The agent must restore the working tree
145
- * to the clean round's state before proceeding. */
146
- export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
142
+ * The rollback prompt and the state file's Recent tier used to carry two
143
+ * hand-written copies of these lines — differing in punctuation
144
+ * ("the redo is round N" vs "redo round N") and in whether an approach
145
+ * overflow count was shown at all. Two renderers over one rollback meant the
146
+ * two surfaces could describe the same event differently while both claiming
147
+ * to be derived facts. The facts are supplied by the caller: the coordinator
148
+ * from its live walk, the compile path from `decodeBacktrackRecord` on the
149
+ * committed decision. */
150
+ export function recoveryBriefLines(input) {
151
+ const maxApproaches = input.maxApproaches ?? 6;
152
+ const maxAssumptions = input.maxAssumptions ?? 5;
153
+ const lines = [
154
+ `- **Trigger**: ${input.triggerRule || "unknown"}`,
155
+ `- **Restored to round**: ${input.target} (the redo is round ${input.target + 1})`,
156
+ ];
157
+ if (input.recoveryRoundId) {
158
+ lines.push(`- **Recovery Round ID**: \`${input.recoveryRoundId}\``);
159
+ }
160
+ if (input.failedRounds.length > 0) {
161
+ lines.push(`- **Failed rounds**: ${input.failedRounds.join(", ")}`);
162
+ }
163
+ const shown = input.approaches.slice(0, maxApproaches);
164
+ for (const approach of shown) {
165
+ lines.push(` - Failed approach: ${approach}`);
166
+ }
167
+ if (input.approaches.length > shown.length) {
168
+ lines.push(` - … and ${input.approaches.length - shown.length} more failed approaches`);
169
+ }
170
+ if (input.wrongAssumptions.length > 0) {
171
+ lines.push("- **Falsified assumptions** (do not rebuild on these):");
172
+ for (const assumption of input.wrongAssumptions.slice(0, maxAssumptions)) {
173
+ lines.push(` - ${assumption}`);
174
+ }
175
+ }
176
+ return lines;
177
+ }
178
+ export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
147
179
  /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
148
180
  * Used to show the agent exactly what needs to be reverted. */
149
- skippedFiles = [],
181
+ skippedFiles = [],
150
182
  /** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
151
183
  * The agent must discard work back to the clean round's state. */
152
- gitHead) {
184
+ gitHead,
185
+ /** v3.7.1: The redo submission's roundId (loop:<id>:round:<toRound+1>). */
186
+ recoveryRoundId,
187
+ /** v3.7.1: Derived Recovery Brief facts — committed facts + the
188
+ * in-flight attempt only; never rejected payloads. */
189
+ recovery) {
153
190
  const lines = [
154
191
  `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
155
192
  "",
156
193
  ];
194
+ // ── Recovery Brief (v3.7.1) ──────────────────────────────────────────
195
+ // A compact structured summary at the top of the rollback directive. The
196
+ // detailed sections below (workspace restore, discoveries, instructions)
197
+ // keep their existing wording; the brief exists so the agent cannot miss
198
+ // why it was rolled back, what failed, and what the redo round is.
199
+ if (recovery) {
200
+ lines.push("### Recovery Brief", "");
201
+ lines.push(...recoveryBriefLines({
202
+ target: toRound,
203
+ triggerRule,
204
+ failedRounds: recovery.failedRounds,
205
+ approaches: recovery.approaches,
206
+ wrongAssumptions: recovery.wrongAssumptions,
207
+ recoveryRoundId,
208
+ }));
209
+ lines.push("");
210
+ }
157
211
  // ── Why This Happened ────────────────────────────────────────────────
158
212
  // v3.7: stall and flatline are one evaluator emitting progress_stall; the
159
213
  // former flatline-specific wording ("zero forward motion → radically
@@ -180,24 +234,33 @@ gitHead) {
180
234
  }
181
235
  lines.push("");
182
236
  }
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>`.", "");
187
- // v2.12: Restore to the exact commit observed at the clean round.
237
+ // v3.8: FACTS, not commands. LoopForge never mutates the working tree, and it
238
+ // does not prescribe how the agent restores it either — the restore is the
239
+ // AGENT's action, so the prompt states what must be true (the target HEAD and
240
+ // the files that must be reverted) and leaves the means entirely to the
241
+ // agent. No `git stash` / `git reset` / `git checkout` / `git clean` line
242
+ // belongs here.
188
243
  if (gitHead) {
189
- lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
190
- "gate checks that your working tree returns to this commit before " +
191
- "accepting your next submission.", "", "```bash", "# Option C: If the failed rounds created commits, reset HEAD to the restore point", `git reset --hard ${gitHead.slice(0, 12)}`, "```", "", "> Option C discards the failed rounds' commits — use it only when those", "> commits belong to the failed work.", "");
244
+ lines.push("**The workspace must be returned to the restore point before you " +
245
+ "continue:**", "", `- HEAD must be at \`${gitHead.slice(0, 12)}\``, `- Every file listed above must be back in its state at Round ${toRound}`, `- No leftover change from rounds ${toRound + 1}–${fromRound} may remain`, "", "Your next submission's git observation is compared against that HEAD, " +
246
+ "and a file whose fingerprint still matches the failed round proves the " +
247
+ "restore did not happen.", "");
192
248
  }
193
- lines.push(`**If git is unavailable**, manually revert the files listed above ` +
194
- `to their state at Round ${toRound}. Re-read the state file below — ` +
249
+ else {
250
+ lines.push(`**The workspace must be returned to its state at Round ${toRound}** — ` +
251
+ "every file listed above reverted, with nothing left over from the " +
252
+ "failed rounds.", "");
253
+ }
254
+ lines.push("Restoring the workspace is **your** responsibility. LoopForge never " +
255
+ "modifies the working tree; it only records the restore requirement and " +
256
+ "checks the result.", "", `**If git is unavailable**, revert the files listed above by hand to ` +
257
+ `their state at Round ${toRound}. Re-read the state file below — ` +
195
258
  `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
196
259
  "> your workspace no longer contains the failed changes. Submissions " +
197
260
  "> with unrestored files will be **rejected**.", "");
198
261
  // ── What Must Change ──────────────────────────────────────────────────
199
262
  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 " +
200
- "(`canceled_subtasks`) and working on a different one.",
263
+ "(`subgoal_updates` to canceled) and working on a different one.",
201
264
  // v3.5: the restored Current Task may be the very Round Contract that
202
265
  // stalled — the agent must be told the sanctioned way to revise it
203
266
  // (outcome=blocked closes the active contract; the revised proposal in
@@ -226,7 +289,7 @@ gitHead) {
226
289
  // Individual enforcement rules — each returns EnforcementResult | null
227
290
  // Rules are ordered by priority. The first non-null result wins.
228
291
  // ═══════════════════════════════════════════════════════════════════════════
229
- /** R1: Agent claims success but success_criteria_remaining has items.
292
+ /** R1: Agent claims success but declared criteria remain outstanding.
230
293
  * This is a lie — the agent must either finish the criteria or
231
294
  * set success=false honestly. Triggered by the verification gate's
232
295
  * "success_with_remaining_criteria" error flag. */
@@ -333,118 +396,91 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
333
396
  "test evidence and no test results were observed. Success must be backed " +
334
397
  "by machine-verifiable evidence.",
335
398
  fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
336
- "report results in execution_evidence.test_results, or (b) configure a " +
399
+ "report results in execution_report.tests_reported, or (b) configure a " +
337
400
  "required verification command and run it, or (c) if no change was " +
338
401
  "genuinely needed, declare no_change_reason explaining why." + escalation,
339
402
  check: "success_without_verified_evidence",
340
403
  });
341
404
  }
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)
405
+ /** v3.8: Verification debt — contract items claimed met without machine
406
+ * verification. The round COMMITS (insufficient is recorded, not rejected);
407
+ * this row only fires once the debt has persisted for
408
+ * `engine.unverified_claim_streak_limit` consecutive rounds, then runs its
409
+ * OWN ladder: reject → terminate as `incomplete`.
410
+ *
411
+ * v3.8.1: the ladder lives here (RULE_TABLE marks the row "internal").
412
+ * Declaring it "uniform" as well ran the escalation twice — the notice was
413
+ * appended twice to one fix_instructions — and made the uniform terminate
414
+ * branch unreachable, while its bare `consecutiveRejections` read let a
415
+ * streak earned by a DIFFERENT check terminate this row on its FIRST
416
+ * occurrence, which is exactly what the L4 rule forbids.
417
+ *
418
+ * The streak is derived from COMMITTED round flags, so it survives restart
419
+ * and cannot be moved by an agent self-report. Git motion does not excuse
420
+ * it: churning code without verifying is exactly what this row catches. */
421
+ function enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
422
+ const limit = getPolicy().engine.unverified_claim_streak_limit;
423
+ if (!Number.isFinite(limit) || limit <= 0)
350
424
  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
- });
425
+ const current = flags.find((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
426
+ if (!current)
427
+ return null;
428
+ let streak = 1;
429
+ for (let round = currentRound - 1; round >= 1; round--) {
430
+ const entry = vaultEntries.find((item) => entryRound(item) === round && String(item.task_id ?? "").endsWith(":feedback"));
431
+ const committed = entry ? decodeCommittedRound(entry) : null;
432
+ const carried = committed?.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
433
+ if (!carried)
434
+ break;
435
+ streak++;
358
436
  }
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)
437
+ if (streak < limit)
388
438
  return null;
389
- if (consecutiveRejections >= 2) {
439
+ // The counter measures the PREVIOUS round's check (the coordinator resets it
440
+ // whenever the check changes), so it only speaks for this row when it names
441
+ // this row — same rule the uniform rows apply.
442
+ const ownRejections = lastRejectionCheck === "contract_items_unverified"
443
+ ? consecutiveRejections
444
+ : 0;
445
+ if (ownRejections >= 2) {
390
446
  return makeEnforcementResult({
391
447
  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",
448
+ reason: `Verification debt persisted for ${streak} rounds — contract items ` +
449
+ `claimed met while no bound command was observed passing.`,
450
+ check: "contract_items_unverified",
451
+ stopReason: "incomplete",
395
452
  });
396
453
  }
397
454
  const escalation = getPolicy().engine.enforcement_escalation_enabled &&
398
- consecutiveRejections >= 1
455
+ ownRejections >= 1
399
456
  ? buildEscalationNotice()
400
457
  : "";
401
458
  return makeEnforcementResult({
402
459
  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",
460
+ reason: current.detail,
461
+ fix_instructions: `Contract items have been claimed met for ${streak} consecutive rounds ` +
462
+ "without machine verification. You must either: (a) run the item's bound " +
463
+ "command(s) so they are observed passing, or (b) report the item as " +
464
+ "remaining / declare outcome: \"blocked\" with a blocker. " +
465
+ "An unverified claim is never accepted as completion." + escalation,
466
+ check: "contract_items_unverified",
413
467
  });
414
468
  }
415
469
  /** R-C2 (v3.3): Round Contract scope drift. The gate produced
416
470
  * 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 = []) {
471
+ * scope. v3.8: scope drift is a MACHINE fact and is no longer waivable by an
472
+ * agent explanation (the drift_clarification channel was deleted) — the
473
+ * agent must revert the out-of-scope changes or close the active contract
474
+ * and declare an extended scope. Repeated drift terminates. */
475
+ function enforceScopeDrift(flags, consecutiveRejections) {
423
476
  const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
424
477
  if (!flag)
425
478
  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
479
  if (consecutiveRejections >= 2) {
444
480
  return makeEnforcementResult({
445
481
  action: "terminate",
446
- reason: `Repeated scope drift without substantive drift_clarification — ` +
447
- `the agent keeps changing files outside its declared contract scope.`,
482
+ reason: `Repeated scope drift — the agent keeps changing files outside its ` +
483
+ `declared contract scope.`,
448
484
  check: "round_scope_drift",
449
485
  });
450
486
  }
@@ -455,12 +491,21 @@ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries
455
491
  return makeEnforcementResult({
456
492
  action: "reject",
457
493
  reason: flag.detail,
494
+ // L3 (v3.7.x): option (b) previously told the agent to "extend the scope
495
+ // in your re-declared round_contract" — structurally unreachable: while a
496
+ // contract is ACTIVE, a replacement proposal is ignored as premature, so
497
+ // the guidance dead-ended into repeated rejects/termination. The only
498
+ // legal scope change is to CLOSE the active contract first: resubmit with
499
+ // outcome="blocked" (blocker naming the too-narrow scope) and the
500
+ // extended scope as the NEXT round's contract in the same submission.
458
501
  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,
502
+ "(a) revert the out-of-scope changes, or (b) legally extend the scope: " +
503
+ "a replacement contract is IGNORED while the current one is active, so " +
504
+ "resubmit this work with outcome: \"blocked\" (blocker naming the " +
505
+ "too-narrow scope) plus the extended scope as the next round's " +
506
+ "round_contract. Scope drift is a machine fact — no explanation waives " +
507
+ "it. Repeated scope drift terminates the loop." +
508
+ escalation,
464
509
  check: "round_scope_drift",
465
510
  });
466
511
  }
@@ -489,27 +534,57 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
489
534
  return makeEnforcementResult({
490
535
  action: "backtrack",
491
536
  reason: "The workspace was not restored after backtrack: " + flag.detail,
492
- fix_instructions: "Restore the working directory to the clean round's state " +
493
- "(git checkout -- . && git clean -fd, or git stash; if the failed " +
494
- "rounds created commits, git reset --hard <restore-commit>) before " +
495
- "submitting the next round.",
537
+ // v3.8: the requirement, not the recipe — LoopForge never mutates the
538
+ // working tree and does not prescribe how the agent restores it.
539
+ fix_instructions: "Return the working directory to the clean round's state before " +
540
+ "submitting the next round: the skipped-round files must be reverted " +
541
+ "and HEAD must be back at the restore point the backtrack prompt named.",
496
542
  check: "backtrack_workspace_not_restored",
497
543
  });
498
544
  }
499
545
  /** Shared observation pipeline for the progress evaluator (stall window,
500
546
  * flatline tier). Machine git motion is exculpatory only: it may veto a
501
547
  * self-reported stall but can never create one. */
502
- function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
548
+ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries,
549
+ /** v3.8.1: the round's success is machine-verified (`deriveEvidenceStatus`).
550
+ * A verified round is finishing work; a motion window cannot outvote it. */
551
+ machineBackedSuccess) {
552
+ // A round the machine verified is not churn, whatever the window says. This
553
+ // is the machine-backed replacement for the old self-reported `>= 0.95`
554
+ // near-completion guard: it lets a loop FINISH (a closing round that only
555
+ // runs the verification and declares success changes no files).
556
+ if (machineBackedSuccess)
557
+ return null;
503
558
  const window = tier === "progress_stall"
504
559
  ? 3
505
560
  : Math.max(1, getPolicy().engine.stall_lookback_rounds);
506
561
  if (currentRound < window)
507
562
  return null;
508
563
  const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
564
+ const threshold = getPolicy().evolution.progress_stall_threshold;
509
565
  let stalled = false;
510
566
  let detail = "";
511
- const threshold = getPolicy().evolution.progress_stall_threshold;
512
- if (progressByRound.size >= window) {
567
+ // v3.8.1: when the MACHINE series is observable it decides the verdict, and
568
+ // the agent's own estimates are not consulted at all. Reading the estimates
569
+ // first (the pre-3.8.1 order) let an agent cancel a stall by writing a
570
+ // rising `progress_estimate` every round — a verdict about machine motion
571
+ // that the agent's own report could move, which is the one thing an external
572
+ // verifier must not allow. Machine motion still only ever EXCUSES: it can
573
+ // veto a stall it did not create; it can never create one.
574
+ //
575
+ // The self-reported window is the FALLBACK for a loop with no machine
576
+ // history to read (no git provider, or fewer rounds than the window). There
577
+ // the evaluator can only catch a stall the agent's own reports state, and
578
+ // the near-completion guard keeps it from rejecting a loop that reports
579
+ // itself nearly done.
580
+ const machine = machineProgressSeries(vaultEntries, currentRound, window);
581
+ if (machine !== null) {
582
+ stalled = machine.every((value) => !value);
583
+ detail = tier === "progress_stall"
584
+ ? `no machine-observed changes in the last ${window} rounds`
585
+ : `no machine-observed changes for ${window} consecutive rounds`;
586
+ }
587
+ else if (progressByRound.size >= window) {
513
588
  const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
514
589
  if (recent[0] < currentRound - window)
515
590
  return null;
@@ -518,7 +593,8 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
518
593
  return null;
519
594
  if (tier === "progress_stall") {
520
595
  stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
521
- detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ");
596
+ detail = `${values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ")}` +
597
+ " (agent-reported, no machine history to read)";
522
598
  }
523
599
  else {
524
600
  const first = values[0];
@@ -527,22 +603,10 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
527
603
  }
528
604
  }
529
605
  else {
530
- const machine = machineProgressSeries(vaultEntries, currentRound, window);
531
- if (machine === null)
532
- return null;
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`;
606
+ return null;
537
607
  }
538
608
  if (!stalled)
539
609
  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
610
  return { tier, detail, window, threshold };
547
611
  }
548
612
  /** Shared progress escalation and backtrack deadlock guard (v3.7: one
@@ -616,9 +680,9 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
616
680
  * shorter lookback configs), where it applies its steeper disposition
617
681
  * ladder. Machine git motion is exculpatory only — it may veto a
618
682
  * 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);
683
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess) {
684
+ const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries, machineBackedSuccess)
685
+ ?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries, machineBackedSuccess);
622
686
  return verdict
623
687
  ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
624
688
  : null;
@@ -666,212 +730,6 @@ function enforceMaxRejections(consecutiveRejections) {
666
730
  check: "max_rejections",
667
731
  });
668
732
  }
669
- /** v2.12: Check if a drift_clarification contains at least one semantic
670
- * anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
671
- * reference. This distinguishes genuine explanations from filler text that
672
- * just happens to be ≥ 20 characters. */
673
- /** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
674
- * file-path-like tokens. */
675
- function extractAnchors(clarification) {
676
- const anchors = new Set();
677
- for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
678
- anchors.add(match[0].toLowerCase());
679
- }
680
- for (const path of extractFilePathTokens(clarification)) {
681
- anchors.add(path);
682
- }
683
- return [...anchors];
684
- }
685
- /** v2.12: A drift clarification is substantive only when at least one anchor
686
- * is REAL — a stable ID that derives from a known constraint/criterion/
687
- * sub-goal text in the vault, or a file path present in reported/observed
688
- * files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
689
- * count, closing the v2.12 filler-text loophole. */
690
- function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
691
- const anchors = extractAnchors(clarification);
692
- if (anchors.length === 0)
693
- return false;
694
- const knownFiles = new Set();
695
- const knownTexts = new Set();
696
- const collect = (values) => {
697
- if (!Array.isArray(values))
698
- return;
699
- for (const value of values) {
700
- if (typeof value === "string" && value.length > 0)
701
- knownTexts.add(value);
702
- }
703
- };
704
- // The agent's own round report is a known set: its constraints, criteria,
705
- // and files are legitimate anchor targets.
706
- for (const file of selfEval.execution_evidence?.files_changed ?? [])
707
- knownFiles.add(file);
708
- collect(selfEval.discovered_constraints);
709
- collect(selfEval.constraint_violations);
710
- const selfEvidence = selfEval.execution_evidence;
711
- if (selfEvidence) {
712
- collect(selfEvidence.success_criteria_met);
713
- collect(selfEvidence.success_criteria_remaining);
714
- }
715
- for (const entry of vaultEntries) {
716
- collect(entry.constraint_violations);
717
- collect(entry.discovered_constraints);
718
- const ev = entry.execution_evidence;
719
- if (ev) {
720
- collect(ev.files_changed);
721
- collect(ev.success_criteria_met);
722
- collect(ev.success_criteria_remaining);
723
- }
724
- }
725
- for (const anchor of anchors) {
726
- if (STABLE_ID_RE.test(anchor)) {
727
- const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
728
- ? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
729
- : anchor.startsWith("cr-")
730
- ? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
731
- : [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
732
- if (matches)
733
- return true;
734
- continue;
735
- }
736
- // File path: exact or suffix match against known files.
737
- if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
738
- return true;
739
- }
740
- }
741
- return false;
742
- }
743
- /** Build a stronger rejection notice as the clarification streak grows. */
744
- function buildClarificationRejectionNotice(streak) {
745
- const base = "Your declared next_action from the previous round does not match " +
746
- "what you actually did this round. You must either: " +
747
- "(a) explain why you pivoted in the drift_clarification field, " +
748
- "mentioning specific constraint/criterion/sub-goal IDs or file paths " +
749
- "that prompted the change, or " +
750
- "(b) redo this round and do what you said you would do.";
751
- if (streak >= 2) {
752
- return (base +
753
- "\n\n### ⚠️ Repeated Weak Clarification\n\n" +
754
- `You have submitted drift_clarification without concrete references ` +
755
- `for ${streak} consecutive rounds. Your explanation MUST include at ` +
756
- `least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
757
- `sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
758
- `🚨 If the next clarification also lacks concrete anchors, the loop ` +
759
- `will be terminated.`);
760
- }
761
- return base;
762
- }
763
- /** R7: Agent declared a next_action but did something unrelated.
764
- *
765
- * v2.8: When the agent provides a substantive drift_clarification (≥ 20
766
- * characters), the rejection is waived — the agent has acknowledged and
767
- * explained the pivot. An empty or absent clarification still triggers
768
- * the normal reject/terminate path.
769
- *
770
- * v2.12: Clarification now requires a semantic anchor (constraint/criterion/
771
- * sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
772
- * clarifications (no anchors) increment a streak counter; 3 consecutive
773
- * weak clarifications terminate the loop.
774
- *
775
- * First occurrence → reject (agent must explain the gap or realign).
776
- * Second consecutive → terminate (agent cannot/will not follow its own plan). */
777
- function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
778
- const flag = flags.find((f) => f.check === "intent_drift");
779
- if (!flag)
780
- return null;
781
- const clarification = selfEval.drift_clarification?.trim();
782
- // v2.12: Substantive anchors — the referenced ID/path must actually exist
783
- // in the vault or in reported files; a fabricated ID no longer counts.
784
- const hasAnchor = clarification
785
- ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
786
- : false;
787
- // v2.12: Substantive clarification = length check + semantic anchor
788
- const isSubstantive = clarification
789
- && clarification.length >= 20
790
- && hasAnchor;
791
- if (isSubstantive) {
792
- // Genuine explanation with concrete references → accept the pivot
793
- return makeEnforcementResult({
794
- action: "accept",
795
- reason: "",
796
- fix_instructions: "",
797
- check: "intent_drift",
798
- clarification_accepted: true,
799
- });
800
- }
801
- // v2.12: Clarification present but weak (no semantic anchors)
802
- if (clarification && clarification.length >= 20 && !hasAnchor) {
803
- const maxStreak = getPolicy().engine.drift_clarification_max_streak;
804
- // When maxStreak is 0, disable the check (pre-v2.12 behavior)
805
- if (maxStreak <= 0) {
806
- return makeEnforcementResult({
807
- action: "accept",
808
- reason: "",
809
- fix_instructions: "",
810
- check: "intent_drift",
811
- clarification_accepted: true,
812
- });
813
- }
814
- const newStreak = driftClarificationStreak + 1;
815
- if (newStreak >= maxStreak) {
816
- return makeEnforcementResult({
817
- action: "terminate",
818
- reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
819
- `file references) for ${newStreak} consecutive rounds. The agent ` +
820
- `systematically avoids following its own plan without substantive ` +
821
- `explanation.`,
822
- check: "intent_drift",
823
- });
824
- }
825
- return makeEnforcementResult({
826
- action: "reject",
827
- reason: flag.detail,
828
- fix_instructions: buildClarificationRejectionNotice(newStreak),
829
- check: "intent_drift",
830
- });
831
- }
832
- // No clarification or too short — the worst form of drift. v2.14: counts
833
- // toward the SAME streak as weak clarifications (maxStreak consecutive
834
- // un-explained drifts terminate, per the README's "three consecutive weak
835
- // clarifications"). Previously this branch used the GLOBAL
836
- // consecutiveRejections counter, so an unrelated earlier rejection
837
- // (e.g. R1) terminated the loop on the very first drift.
838
- const maxStreak = getPolicy().engine.drift_clarification_max_streak;
839
- // When maxStreak is 0, disable the check (pre-v2.12 behavior)
840
- if (maxStreak <= 0) {
841
- return makeEnforcementResult({
842
- action: "accept",
843
- reason: "",
844
- fix_instructions: "",
845
- check: "intent_drift",
846
- clarification_accepted: true,
847
- });
848
- }
849
- const newStreak = driftClarificationStreak + 1;
850
- if (newStreak >= maxStreak) {
851
- return makeEnforcementResult({
852
- action: "terminate",
853
- reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
854
- `consecutive rounds without any drift_clarification. The agent ` +
855
- `cannot or will not follow its own plan — terminating loop.`,
856
- check: "intent_drift",
857
- });
858
- }
859
- return makeEnforcementResult({
860
- action: "reject",
861
- reason: flag.detail,
862
- fix_instructions: "Your declared next_action from the previous round does not match " +
863
- "what you actually did this round. You must either: " +
864
- "(a) explain why you pivoted and what you learned in the " +
865
- "drift_clarification field, or " +
866
- "(b) redo this round and do what you said you would do. " +
867
- "If you intentionally changed direction, update your objective_refinement " +
868
- "and set a new next_action that reflects the new plan.",
869
- check: "intent_drift",
870
- });
871
- }
872
- // ═══════════════════════════════════════════════════════════════════════════
873
- // Escalation notice builder (v2.7)
874
- // ═══════════════════════════════════════════════════════════════════════════
875
733
  /** Build an escalation notice urging the agent to seek human guidance.
876
734
  *
877
735
  * Appended to fix_instructions when a rule would otherwise terminate.
@@ -922,22 +780,21 @@ function buildDiagnosticGap(flags) {
922
780
  return lines.join("\n");
923
781
  }
924
782
  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 },
783
+ { checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
784
+ { checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
785
+ { checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
786
+ { checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
787
+ { checks: ["success_without_verified_evidence"], ladder: "internal" },
788
+ { checks: ["round_scope_drift"], ladder: "internal" },
789
+ // v3.8.1: "internal" — the handler owns this row's ladder (it must terminate
790
+ // as `incomplete`, with its own reason). Declaring it uniform as well ran the
791
+ // escalation notice twice and left the uniform terminate branch dead.
792
+ { checks: ["contract_items_unverified"], ladder: "internal" },
793
+ { checks: ["progress_stall"], ladder: "internal" },
794
+ { checks: ["max_rejections"], ladder: "counter" },
795
+ { checks: ["backtrack_workspace_not_restored"], ladder: "internal" },
796
+ // v3.7.1: cited gates without an approved human decision
797
+ { checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
941
798
  ];
942
799
  const RULE_TABLE_BY_ID = new Map();
943
800
  for (const row of RULE_TABLE) {
@@ -961,9 +818,33 @@ for (const row of RULE_TABLE) {
961
818
  * @param consecutiveRejections How many consecutive rounds have already been rejected.
962
819
  * Starts at 0; increments on each reject; resets on accept.
963
820
  */
964
- export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
965
- /** v2.12: Current clarification streak for R7 escalation. */
966
- driftClarificationStreak = 0) {
821
+ /** v3.7.1: user_gate_unresolved — the round cites gates without an
822
+ * approved human decision. Reject; the uniform ladder escalates repeats
823
+ * (the agent should pause and obtain approval — never resubmit a claim of
824
+ * completed high-risk work). */
825
+ function enforceUserGateUnresolved(flags) {
826
+ const flag = flags.find((f) => f.check === CHECK_USER_GATE_UNRESOLVED && f.severity === "error");
827
+ if (!flag)
828
+ return null;
829
+ return makeEnforcementResult({
830
+ action: "reject",
831
+ reason: flag.detail,
832
+ fix_instructions: "The round depends on a high-risk action with no approved human " +
833
+ "decision. Run loopforge_gate_check with the action, present the " +
834
+ "approval question, and call loopforge_gate_resolve only after the " +
835
+ "human decides. Then resubmit with the approved gate id in " +
836
+ "evaluation.gate_ids. Do NOT claim the action is done while it is " +
837
+ "unapproved — safe preparation work never needs a gate.",
838
+ check: CHECK_USER_GATE_UNRESOLVED,
839
+ });
840
+ }
841
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
842
+ /** L4 (v3.7.x): which check rejected the PREVIOUS round. The coordinator
843
+ * resets consecutiveRejections to 1 whenever the check changes, so the
844
+ * counter only ever measures one check's streak — this field names that
845
+ * check and lets uniform rows escalate on THEIR OWN streak instead of
846
+ * inheriting an unrelated history. */
847
+ lastRejectionCheck = "") {
967
848
  const { flags } = verifyResult;
968
849
  // Run enforcement rules in priority order — the array order IS the
969
850
  // priority; the R-labels in the comments are historical (RULE_TABLE is
@@ -981,25 +862,25 @@ driftClarificationStreak = 0) {
981
862
  // v3.5: contract completion claimed without its verification_plan
982
863
  // commands passing — runs before R-C1: the completion-truth question
983
864
  // outranks boundary nuance.
984
- () => enforceContractCompletionUnverified(flags, consecutiveRejections),
985
865
  // v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
986
866
  // wording and check counting; runs before R8 so contract rounds get the
987
867
  // contract's own reason.)
988
- () => enforcePrematureBoundary(flags, consecutiveRejections),
989
868
  // v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
990
869
  // arm merged from the former R3 empty_success posture + claims arm)
991
870
  () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
992
871
  // v3.3: R-C2 — files changed outside the contract's declared scope
993
- () => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
872
+ () => enforceScopeDrift(flags, consecutiveRejections),
873
+ // v3.8: verification debt — contract items claimed met without machine
874
+ // verification (fires only after the configured streak)
875
+ () => enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck),
994
876
  // v3.7: single progress evaluator (former R4/R5 slots merged) —
995
877
  // reject → backtrack → terminate with the deadlock guard
996
- () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
878
+ () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess),
997
879
  // R6: rejection-counter catch-all (evaluated only when no row above
998
880
  // fired — a clean-looking round carrying a high persisted counter)
881
+ // v3.7.1: cited gate without an approved human decision
882
+ () => enforceUserGateUnresolved(flags),
999
883
  () => 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
1002
- () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1003
884
  // v2.12: R9 — post-backtrack workspace not restored
1004
885
  () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1005
886
  ];
@@ -1020,16 +901,26 @@ driftClarificationStreak = 0) {
1020
901
  ? RULE_TABLE_BY_ID.get(result.check)
1021
902
  : undefined;
1022
903
  if (result.action === "reject" && row && row.ladder === "uniform") {
1023
- if (consecutiveRejections >= (row.terminalAfter ?? 2)) {
904
+ // L4: uniform rows escalate on their OWN consecutive streak only.
905
+ // consecutiveRejections is the streak of the PREVIOUS round's check
906
+ // (the coordinator resets it to 1 whenever the check changes) — a
907
+ // streak earned by a DIFFERENT check must not convert this first
908
+ // occurrence into termination. The user_gate row is the sharp case:
909
+ // its recovery is human approval, so a gate cited after unrelated
910
+ // rejections must reject with instructions, not kill the loop.
911
+ const ownStreak = lastRejectionCheck === result.check
912
+ ? consecutiveRejections
913
+ : 0;
914
+ if (ownStreak >= (row.terminalAfter ?? 2)) {
1024
915
  return makeEnforcementResult({
1025
916
  action: "terminate",
1026
- reason: `${consecutiveRejections + 1} consecutive rejections for the same ` +
917
+ reason: `${ownStreak + 1} consecutive rejections for the same ` +
1027
918
  `issue (${result.check}) without resolution — the agent cannot ` +
1028
919
  `correct it even after escalation.`,
1029
920
  check: result.check,
1030
921
  });
1031
922
  }
1032
- if (consecutiveRejections >= 1 && row.noticeOnRepeat &&
923
+ if (ownStreak >= 1 && row.noticeOnRepeat &&
1033
924
  getPolicy().engine.enforcement_escalation_enabled) {
1034
925
  result = makeEnforcementResult({
1035
926
  ...result,