loopforge 3.6.0 → 3.7.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +28 -13
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +3 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +23 -4
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +148 -47
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +8 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts.map +1 -1
  14. package/dist/cognitive-facts.js +2 -3
  15. package/dist/cognitive-facts.js.map +1 -1
  16. package/dist/cognitive-governance.d.ts +30 -7
  17. package/dist/cognitive-governance.d.ts.map +1 -1
  18. package/dist/cognitive-governance.js +75 -22
  19. package/dist/cognitive-governance.js.map +1 -1
  20. package/dist/committed-round.d.ts +48 -4
  21. package/dist/committed-round.d.ts.map +1 -1
  22. package/dist/committed-round.js +177 -10
  23. package/dist/committed-round.js.map +1 -1
  24. package/dist/enforcement-gate.d.ts +24 -19
  25. package/dist/enforcement-gate.d.ts.map +1 -1
  26. package/dist/enforcement-gate.js +216 -146
  27. package/dist/enforcement-gate.js.map +1 -1
  28. package/dist/engine.d.ts +10 -5
  29. package/dist/engine.d.ts.map +1 -1
  30. package/dist/engine.js +52 -31
  31. package/dist/engine.js.map +1 -1
  32. package/dist/evidence-claims.d.ts.map +1 -1
  33. package/dist/evidence-claims.js +3 -9
  34. package/dist/evidence-claims.js.map +1 -1
  35. package/dist/evidence-provider.d.ts +13 -19
  36. package/dist/evidence-provider.d.ts.map +1 -1
  37. package/dist/evidence-provider.js +65 -100
  38. package/dist/evidence-provider.js.map +1 -1
  39. package/dist/generate-schema.js +104 -43
  40. package/dist/generate-schema.js.map +1 -1
  41. package/dist/index.d.ts +3 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/loop-compiler.d.ts +18 -10
  46. package/dist/loop-compiler.d.ts.map +1 -1
  47. package/dist/loop-compiler.js +205 -343
  48. package/dist/loop-compiler.js.map +1 -1
  49. package/dist/loop-store.d.ts +11 -16
  50. package/dist/loop-store.d.ts.map +1 -1
  51. package/dist/loop-store.js +21 -63
  52. package/dist/loop-store.js.map +1 -1
  53. package/dist/mcp/round-lifecycle.d.ts +18 -3
  54. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  55. package/dist/mcp/round-lifecycle.js +162 -35
  56. package/dist/mcp/round-lifecycle.js.map +1 -1
  57. package/dist/mcp/server.d.ts.map +1 -1
  58. package/dist/mcp/server.js +12 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/mcp/session.d.ts +35 -4
  61. package/dist/mcp/session.d.ts.map +1 -1
  62. package/dist/mcp/session.js +169 -52
  63. package/dist/mcp/session.js.map +1 -1
  64. package/dist/mcp/tools.d.ts.map +1 -1
  65. package/dist/mcp/tools.js +170 -39
  66. package/dist/mcp/tools.js.map +1 -1
  67. package/dist/policy.d.ts +10 -13
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +3 -6
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +6 -4
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +46 -100
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +52 -51
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +5 -28
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/round-coordinator.d.ts +19 -0
  80. package/dist/round-coordinator.d.ts.map +1 -1
  81. package/dist/round-coordinator.js +63 -4
  82. package/dist/round-coordinator.js.map +1 -1
  83. package/dist/round-driver.d.ts +4 -3
  84. package/dist/round-driver.d.ts.map +1 -1
  85. package/dist/round-driver.js +3 -10
  86. package/dist/round-driver.js.map +1 -1
  87. package/dist/round-transaction.d.ts +4 -0
  88. package/dist/round-transaction.d.ts.map +1 -1
  89. package/dist/round-transaction.js +9 -0
  90. package/dist/round-transaction.js.map +1 -1
  91. package/dist/self-eval.d.ts +41 -3
  92. package/dist/self-eval.d.ts.map +1 -1
  93. package/dist/self-eval.js +125 -13
  94. package/dist/self-eval.js.map +1 -1
  95. package/dist/token-utils.d.ts +7 -3
  96. package/dist/token-utils.d.ts.map +1 -1
  97. package/dist/token-utils.js +18 -3
  98. package/dist/token-utils.js.map +1 -1
  99. package/dist/verification-gate.d.ts +32 -24
  100. package/dist/verification-gate.d.ts.map +1 -1
  101. package/dist/verification-gate.js +262 -178
  102. package/dist/verification-gate.js.map +1 -1
  103. package/dist/version.d.ts +5 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +5 -0
  106. package/dist/version.js.map +1 -0
  107. package/loop_policy.json +19 -7
  108. package/package.json +6 -6
  109. package/skills/perception/SKILL.md +56 -16
@@ -17,17 +17,16 @@
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, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_PREMATURE_BOUNDARY, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_COMPLETION_UNVERIFIED } 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, CHECK_USER_GATE_UNRESOLVED } 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 { committedRoundsFromEntries, decodeCommittedRound, } from "./committed-round.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
28
  /** Collect progress estimates from vault entries into a round→estimate map.
30
- * Shared by R4 (progress_stall) and R5 (progress_flatline). */
29
+ * Shared by the progress evaluator's stall and flatline tiers. */
31
30
  function collectProgressByRound(vaultEntries, currentRound) {
32
31
  const progressByRound = new Map();
33
32
  for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
@@ -129,9 +128,7 @@ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
129
128
  .find((view) => view.round === restoreRound);
130
129
  if (!committed)
131
130
  return null;
132
- const evidence = committed.afterEvidence.length > 0
133
- ? committed.afterEvidence
134
- : committed.beforeEvidence;
131
+ const evidence = machineEvidenceForRound(committed);
135
132
  for (const item of evidence) {
136
133
  if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
137
134
  continue;
@@ -141,31 +138,62 @@ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
141
138
  }
142
139
  return null;
143
140
  }
144
- /** Build the backtrack prompt injected at the top of the restored round.
145
- *
146
- * v2.13: Includes concrete workspace restore instructions with affected
147
- * file lists from skipped rounds. The agent must restore the working tree
148
- * to the clean round's state before proceeding. */
149
- export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
141
+ export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
150
142
  /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
151
143
  * Used to show the agent exactly what needs to be reverted. */
152
- skippedFiles = [],
144
+ skippedFiles = [],
153
145
  /** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
154
146
  * The agent must discard work back to the clean round's state. */
155
- gitHead) {
147
+ gitHead,
148
+ /** v3.7.1: The redo submission's roundId (loop:<id>:round:<toRound+1>). */
149
+ recoveryRoundId,
150
+ /** v3.7.1: Derived Recovery Brief facts — committed facts + the
151
+ * in-flight attempt only; never rejected payloads. */
152
+ recovery) {
156
153
  const lines = [
157
154
  `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
158
155
  "",
159
156
  ];
157
+ // ── Recovery Brief (v3.7.1) ──────────────────────────────────────────
158
+ // A compact structured summary at the top of the rollback directive. The
159
+ // detailed sections below (workspace restore, discoveries, instructions)
160
+ // keep their existing wording; the brief exists so the agent cannot miss
161
+ // why it was rolled back, what failed, and what the redo round is.
162
+ if (recovery) {
163
+ lines.push("### Recovery Brief", "");
164
+ lines.push(`- **Trigger**: ${triggerRule}`);
165
+ lines.push(`- **Restored to round**: ${toRound} (the redo is round ${toRound + 1})`);
166
+ if (recoveryRoundId) {
167
+ lines.push(`- **Recovery Round ID**: \`${recoveryRoundId}\``);
168
+ }
169
+ if (recovery.failedRounds.length > 0) {
170
+ lines.push(`- **Failed rounds**: ${recovery.failedRounds.join(", ")}`);
171
+ const shown = recovery.approaches.slice(0, 6);
172
+ for (const approach of shown) {
173
+ lines.push(` - Failed approach: ${approach}`);
174
+ }
175
+ if (recovery.approaches.length > shown.length) {
176
+ lines.push(` - … and ${recovery.approaches.length - shown.length} more failed approaches`);
177
+ }
178
+ }
179
+ if (recovery.wrongAssumptions.length > 0) {
180
+ lines.push("- **Falsified assumptions** (do not rebuild on these):");
181
+ for (const assumption of recovery.wrongAssumptions.slice(0, 5)) {
182
+ lines.push(` - ${assumption}`);
183
+ }
184
+ }
185
+ lines.push("");
186
+ }
160
187
  // ── Why This Happened ────────────────────────────────────────────────
188
+ // v3.7: stall and flatline are one evaluator emitting progress_stall; the
189
+ // former flatline-specific wording ("zero forward motion → radically
190
+ // different strategy") is folded into the single section — the disposition
191
+ // tier still distinguishes the enforcement reason itself.
161
192
  if (triggerRule === "progress_stall") {
162
- lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
163
- `The work done in those rounds produced no verifiable progress — ` +
164
- `no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
165
- }
166
- else if (triggerRule === "progress_flatline") {
167
- lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
168
- `The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
193
+ lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}: the work ` +
194
+ `produced no verifiable forward motion — no new files changed, no ` +
195
+ `criteria met, no progress estimates moving.`, "", "The approach used in those rounds **did not work**. It should not be " +
196
+ "repeated — a **radically different** strategy is needed.", "");
169
197
  }
170
198
  // ── v2.13: Workspace Restore ──────────────────────────────────────────
171
199
  lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
@@ -182,7 +210,10 @@ gitHead) {
182
210
  }
183
211
  lines.push("");
184
212
  }
185
- 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", "```", "");
213
+ lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Keep everything as a safety net (recommended) — reversible", `git stash push -u -m "backtrack-safety-net-round-${fromRound}"`, "", "# Option B: Discard ALL uncommitted AND untracked changes (permanent)", "git checkout -- .", "git clean -fd", "```", "", "> Option A is a safety net — do NOT `git stash pop` before submitting " +
214
+ "the next round: the skipped work would look unrestored and be rejected. " +
215
+ "Restore only files you own after the next round commits, via " +
216
+ "`git checkout stash@{0} -- <path>`.", "");
186
217
  // v2.12: Restore to the exact commit observed at the clean round.
187
218
  if (gitHead) {
188
219
  lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
@@ -196,7 +227,7 @@ gitHead) {
196
227
  "> with unrestored files will be **rejected**.", "");
197
228
  // ── What Must Change ──────────────────────────────────────────────────
198
229
  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 " +
199
- "(`canceled_subtasks`) and working on a different one.",
230
+ "(`subgoal_updates` to canceled) and working on a different one.",
200
231
  // v3.5: the restored Current Task may be the very Round Contract that
201
232
  // stalled — the agent must be told the sanctioned way to revise it
202
233
  // (outcome=blocked closes the active contract; the revised proposal in
@@ -305,51 +336,12 @@ function enforceVerificationEntrypointTampered(flags) {
305
336
  check: "verification_entrypoint_modified",
306
337
  });
307
338
  }
308
- /** R3: Agent claims success but did nothing verifiable.
309
- * v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
310
- * Missing evidence when success=true → reject (agent must provide evidence).
311
- * Empty evidence (no files + no tests) when success=true → reject. */
312
- function enforceEmptySuccess(selfEval, _flags) {
313
- if (!effectiveSuccess(selfEval))
314
- return null;
315
- const ev = selfEval.execution_evidence;
316
- // v1.17: Missing evidence when claiming success → reject.
317
- // The agent MUST provide execution_evidence to back up a success claim.
318
- if (!ev) {
319
- return makeEnforcementResult({
320
- action: "reject",
321
- reason: "Agent claims success but provided no execution_evidence. " +
322
- "Every successful round MUST include execution_evidence with " +
323
- "files_changed, test_results, and progress_estimate.",
324
- fix_instructions: "You must provide execution_evidence in your self-evaluation: " +
325
- "(a) list the files you changed in execution_evidence.files_changed, " +
326
- "(b) run tests and report results in execution_evidence.test_results, " +
327
- "(c) estimate your progress in execution_evidence.progress_estimate. " +
328
- "If you genuinely completed the task without file changes or tests, " +
329
- "explain why in detail in your output_summary.",
330
- check: "empty_success",
331
- });
332
- }
333
- const filesEmpty = ev.files_changed.length === 0;
334
- const testsNotRun = ev.test_results === null;
335
- if (!filesEmpty || !testsNotRun)
336
- return null;
337
- return makeEnforcementResult({
338
- action: "reject",
339
- reason: "Agent claims success but execution_evidence shows no files changed " +
340
- "and no tests were run. There is no verifiable evidence of work.",
341
- fix_instructions: "You must provide verifiable evidence: " +
342
- "(a) list the files you changed in execution_evidence.files_changed, " +
343
- "and (b) run tests and report results in execution_evidence.test_results. " +
344
- "If you genuinely completed the task without file changes or tests, " +
345
- "explain why in detail in your output_summary.",
346
- check: "empty_success",
347
- });
348
- }
349
- /** R8 (v2.12): Success claimed without any machine-verifiable evidence.
350
- * The verification gate produced success_without_verified_evidence (error):
351
- * zero verified claims, no test results. First occurrence → reject with
352
- * concrete evidence requirements; second consecutive → terminate. */
339
+ /** R8 (v2.12/v3.7): The single success-evidence row. The verification gate
340
+ * produced success_without_verified_evidence (error) — either the empty/
341
+ * missing-evidence arm (v3.7: the ex-R3 empty_success posture moved into
342
+ * checkSuccessWithoutVerifiedEvidence) or the claims arm (zero verified
343
+ * claims). First occurrence → reject with concrete evidence requirements;
344
+ * second consecutive → terminate. */
353
345
  function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
354
346
  const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
355
347
  if (!flag)
@@ -493,12 +485,22 @@ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries
493
485
  return makeEnforcementResult({
494
486
  action: "reject",
495
487
  reason: flag.detail,
488
+ // L3 (v3.7.x): option (b) previously told the agent to "extend the scope
489
+ // in your re-declared round_contract" — structurally unreachable: while a
490
+ // contract is ACTIVE, a replacement proposal is ignored as premature, so
491
+ // the guidance dead-ended into repeated rejects/termination. The only
492
+ // legal scope change is to CLOSE the active contract first: resubmit with
493
+ // outcome="blocked" (blocker naming the too-narrow scope) and the
494
+ // extended scope as the NEXT round's contract in the same submission.
496
495
  fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
497
- "(a) revert the out-of-scope changes, or (b) extend the scope in your " +
498
- "re-declared round_contract AND explain the pivot in drift_clarification " +
499
- "with concrete file paths (≥ 20 characters, real anchors). Weak or " +
500
- "missing clarifications are rejected; repeated scope drift terminates " +
501
- "the loop." + escalation,
496
+ "(a) revert the out-of-scope changes, or (b) legally extend the scope: " +
497
+ "a replacement contract is IGNORED while the current one is active, so " +
498
+ "resubmit this work with outcome: \"blocked\" (blocker naming the " +
499
+ "too-narrow scope) plus the extended scope as the next round's " +
500
+ "round_contract, and explain the pivot in drift_clarification with " +
501
+ "concrete file paths (≥ 20 characters, real anchors). Weak or missing " +
502
+ "clarifications are rejected; repeated scope drift terminates the loop." +
503
+ escalation,
502
504
  check: "round_scope_drift",
503
505
  });
504
506
  }
@@ -534,8 +536,9 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
534
536
  check: "backtrack_workspace_not_restored",
535
537
  });
536
538
  }
537
- /** Shared R4/R5 observation pipeline. Machine git motion is exculpatory
538
- * only: it may veto a self-reported stall but can never create one. */
539
+ /** Shared observation pipeline for the progress evaluator (stall window,
540
+ * flatline tier). Machine git motion is exculpatory only: it may veto a
541
+ * self-reported stall but can never create one. */
539
542
  function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
540
543
  const window = tier === "progress_stall"
541
544
  ? 3
@@ -582,8 +585,9 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
582
585
  }
583
586
  return { tier, detail, window, threshold };
584
587
  }
585
- /** Shared R4/R5 escalation and backtrack deadlock guard. The flatline tier
586
- * keeps its steeper threshold-0 ladder while using the same mechanics. */
588
+ /** Shared progress escalation and backtrack deadlock guard (v3.7: one
589
+ * evaluator). The flatline tier keeps its steeper threshold-0 ladder while
590
+ * using the same mechanics — the tier is an internal diagnostic only. */
587
591
  function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections) {
588
592
  const { tier, detail, window, threshold } = verdict;
589
593
  const flatline = tier === "progress_flatline";
@@ -592,18 +596,21 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
592
596
  ? `Progress has been ${detail} for ${window} consecutive rounds.`
593
597
  : `Progress has stalled: ${detail} over the last ${window} rounds` +
594
598
  `${threshold > 0 ? ` (delta < ${(threshold * 100).toFixed(0)}% each round)` : ""}.`;
599
+ // v3.7: both tiers emit the single progress_stall check id — the tier
600
+ // survives only in the reason/fix wording (former progress_flatline id).
601
+ const check = "progress_stall";
595
602
  if ((flatline && consecutiveRejections >= 1) || consecutiveRejections >= 2) {
596
603
  return makeEnforcementResult({
597
604
  action: "terminate",
598
605
  reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
599
- check: tier,
606
+ check,
600
607
  });
601
608
  }
602
609
  if (consecutiveRejections >= 1 && !escalation) {
603
610
  return makeEnforcementResult({
604
611
  action: "terminate",
605
612
  reason: `${stalledReason} The stall persisted after the previous rejection. Terminating loop.`,
606
- check: tier,
613
+ check,
607
614
  });
608
615
  }
609
616
  const escalated = flatline ? escalation : consecutiveRejections >= 1 && escalation;
@@ -612,7 +619,7 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
612
619
  return makeEnforcementResult({
613
620
  action: "terminate",
614
621
  reason: `${stalledReason} A backtrack already fired for this round; the stall persists after rollback.`,
615
- check: tier,
622
+ check,
616
623
  });
617
624
  }
618
625
  return makeEnforcementResult({
@@ -621,14 +628,14 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
621
628
  fix_instructions: flatline
622
629
  ? "Progress is exactly flat. After rollback, choose a radically different approach."
623
630
  : "After rollback, do not repeat the stalled approach; choose a different technique or task decomposition.",
624
- check: tier,
631
+ check,
625
632
  });
626
633
  }
627
634
  if (flatline && !escalation) {
628
635
  return makeEnforcementResult({
629
636
  action: "terminate",
630
637
  reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
631
- check: tier,
638
+ check,
632
639
  });
633
640
  }
634
641
  return makeEnforcementResult({
@@ -638,17 +645,20 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
638
645
  ? "Explain the fundamental blocker, choose a radically different approach, and set a concrete verifiable goal."
639
646
  : "Explain the blocker, choose a different technique or task decomposition, and set a concrete verifiable goal for this retry.") +
640
647
  (escalated ? buildEscalationNotice() : ""),
641
- check: tier,
648
+ check,
642
649
  });
643
650
  }
651
+ /** v3.7: The single progress evaluator (former R4 stall and R5 flatline rows
652
+ * merged into one). The stall-tier window is consulted first — under the
653
+ * default positive threshold its predicate covers exactly-flat runs too,
654
+ * reproducing the v3.6 R4-before-R5 shadowing; the flatline tier is
655
+ * consulted only when the stall predicate cannot hold (threshold ≤ 0,
656
+ * shorter lookback configs), where it applies its steeper disposition
657
+ * ladder. Machine git motion is exculpatory only — it may veto a
658
+ * self-reported stall but can never create one. */
644
659
  function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
645
- const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries);
646
- return verdict
647
- ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
648
- : null;
649
- }
650
- function enforceProgressFlatline(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
651
- const verdict = evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
660
+ const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries)
661
+ ?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
652
662
  return verdict
653
663
  ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
654
664
  : null;
@@ -707,8 +717,8 @@ function extractAnchors(clarification) {
707
717
  for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
708
718
  anchors.add(match[0].toLowerCase());
709
719
  }
710
- for (const match of clarification.matchAll(/[\w./-]+\.[a-z]{2,6}\b/gi)) {
711
- anchors.add(match[0]);
720
+ for (const path of extractFilePathTokens(clarification)) {
721
+ anchors.add(path);
712
722
  }
713
723
  return [...anchors];
714
724
  }
@@ -951,6 +961,31 @@ function buildDiagnosticGap(flags) {
951
961
  lines.push("");
952
962
  return lines.join("\n");
953
963
  }
964
+ const RULE_TABLE = [
965
+ // R1/R2 join the contradiction umbrella per the documented four-class model
966
+ // (AGENTS/README/CHANGELOG): a row's category is its enforcement action
967
+ // class, NOT the trigger check's gate domain — claim-vs-self-reported-facts
968
+ // rows (outcome_success_contradiction included) belong to this class too.
969
+ { category: "evidence_contradiction", checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
970
+ { category: "evidence_contradiction", checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
971
+ { category: "evidence_contradiction", checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
972
+ { category: "evidence_contradiction", checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
973
+ { category: "contract_scope", checks: ["contract_completion_unverified"], ladder: "internal" },
974
+ { category: "contract_scope", checks: ["premature_boundary"], ladder: "internal" },
975
+ { category: "evidence_contradiction", checks: ["success_without_verified_evidence"], ladder: "internal" },
976
+ { category: "contract_scope", checks: ["round_scope_drift"], ladder: "internal", clarificationAllowed: true },
977
+ { category: "progress_recovery", checks: ["progress_stall"], ladder: "internal", backtrackAllowed: true },
978
+ { category: "progress_recovery", checks: ["max_rejections"], ladder: "counter" },
979
+ { category: "plan_drift", checks: ["intent_drift"], ladder: "internal", clarificationAllowed: true },
980
+ { category: "progress_recovery", checks: ["backtrack_workspace_not_restored"], ladder: "internal", backtrackAllowed: true },
981
+ // v3.7.1: cited gates without an approved human decision
982
+ { category: "contract_scope", checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
983
+ ];
984
+ const RULE_TABLE_BY_ID = new Map();
985
+ for (const row of RULE_TABLE) {
986
+ for (const id of row.checks)
987
+ RULE_TABLE_BY_ID.set(id, row);
988
+ }
954
989
  // ═══════════════════════════════════════════════════════════════════════════
955
990
  // Main entry point
956
991
  // ═══════════════════════════════════════════════════════════════════════════
@@ -968,84 +1003,119 @@ function buildDiagnosticGap(flags) {
968
1003
  * @param consecutiveRejections How many consecutive rounds have already been rejected.
969
1004
  * Starts at 0; increments on each reject; resets on accept.
970
1005
  */
971
- export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
1006
+ /** v3.7.1: user_gate_unresolved — the round cites gates without an
1007
+ * approved human decision. Reject; the uniform ladder escalates repeats
1008
+ * (the agent should pause and obtain approval — never resubmit a claim of
1009
+ * completed high-risk work). */
1010
+ function enforceUserGateUnresolved(flags) {
1011
+ const flag = flags.find((f) => f.check === CHECK_USER_GATE_UNRESOLVED && f.severity === "error");
1012
+ if (!flag)
1013
+ return null;
1014
+ return makeEnforcementResult({
1015
+ action: "reject",
1016
+ reason: flag.detail,
1017
+ fix_instructions: "The round depends on a high-risk action with no approved human " +
1018
+ "decision. Run loopforge_gate_check with the action, present the " +
1019
+ "approval question, and call loopforge_gate_resolve only after the " +
1020
+ "human decides. Then resubmit with the approved gate id in " +
1021
+ "evaluation.gate_ids. Do NOT claim the action is done while it is " +
1022
+ "unapproved — safe preparation work never needs a gate.",
1023
+ check: CHECK_USER_GATE_UNRESOLVED,
1024
+ });
1025
+ }
1026
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
972
1027
  /** v2.12: Current clarification streak for R7 escalation. */
973
- driftClarificationStreak = 0) {
1028
+ driftClarificationStreak = 0,
1029
+ /** L4 (v3.7.x): which check rejected the PREVIOUS round. The coordinator
1030
+ * resets consecutiveRejections to 1 whenever the check changes, so the
1031
+ * counter only ever measures one check's streak — this field names that
1032
+ * check and lets uniform rows escalate on THEIR OWN streak instead of
1033
+ * inheriting an unrelated history. */
1034
+ lastRejectionCheck = "") {
974
1035
  const { flags } = verifyResult;
975
- // Run enforcement rules in priority order.
976
- // Earlier rules take higher precedence.
1036
+ // Run enforcement rules in priority order — the array order IS the
1037
+ // priority; the R-labels in the comments are historical (RULE_TABLE is
1038
+ // the semantic description). The first rule that fires wins.
977
1039
  const rules = [
978
1040
  () => enforceSuccessWithRemainingCriteria(flags),
979
1041
  () => enforceRecurringViolation(flags),
980
- () => enforceEmptySuccess(selfEval, flags),
981
- // v2.14: R-EVID — a required command failure or self-contradictory
982
- // outcome claim contradicts the success claim before commit
1042
+ // v2.14: R-EVID — a required command failure, hidden test failures, or
1043
+ // a self-contradictory outcome claim contradicts the success claim
983
1044
  () => enforceEvidenceContradiction(flags),
984
1045
  // v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
985
- // the same round it ran; its result cannot back the success claim.
986
- // Runs before R8: when the entrypoint is tainted, R8 also fires (the
987
- // status degrades to unavailable), but this reason is more specific.
1046
+ // the same round it ran. Runs before R8: when the entrypoint is tainted
1047
+ // R8 also fires, but this reason is more specific.
988
1048
  () => enforceVerificationEntrypointTampered(flags),
989
1049
  // v3.5: contract completion claimed without its verification_plan
990
1050
  // commands passing — runs before R-C1: the completion-truth question
991
- // outranks boundary nuance (a completing eval that also lies about its
992
- // evidence gets the completion reason first).
1051
+ // outranks boundary nuance.
993
1052
  () => enforceContractCompletionUnverified(flags, consecutiveRejections),
994
1053
  // v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
995
1054
  // wording and check counting; runs before R8 so contract rounds get the
996
- // contract's own reason. R8 itself is untouched.)
1055
+ // contract's own reason.)
997
1056
  () => enforcePrematureBoundary(flags, consecutiveRejections),
998
- // v2.12: R8 — success with zero machine-verifiable evidence
1057
+ // v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
1058
+ // arm merged from the former R3 empty_success posture + claims arm)
999
1059
  () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
1000
1060
  // v3.3: R-C2 — files changed outside the contract's declared scope
1001
1061
  () => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
1002
- // v3.3.1: R4 is evaluated first and its stall predicate contains R5's
1003
- // flatness predicate — under the default ladder R4 proxies for flat runs
1004
- // too (reject → backtrack/terminate). R5 stays as the reserved severity
1005
- // tier (see its doc comment: active when progress_stall_threshold <= 0
1006
- // or after a future window split).
1062
+ // v3.7: single progress evaluator (former R4/R5 slots merged) —
1063
+ // reject → backtrack → terminate with the deadlock guard
1007
1064
  () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
1008
- () => enforceProgressFlatline(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
1065
+ // R6: rejection-counter catch-all (evaluated only when no row above
1066
+ // fired — a clean-looking round carrying a high persisted counter)
1067
+ // v3.7.1: cited gate without an approved human decision
1068
+ () => enforceUserGateUnresolved(flags),
1009
1069
  () => enforceMaxRejections(consecutiveRejections),
1070
+ // v2.12: R7 — intent drift; its weak-clarification streak is a separate
1071
+ // counter and never mixes with the global rejection count
1010
1072
  () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1011
1073
  // v2.12: R9 — post-backtrack workspace not restored
1012
1074
  () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1013
1075
  ];
1014
- // v3.3.1: rules without an internal escalation ladder (R1/R2/R3/R-EVID/
1015
- // R-EVID-VERIFY) reject on EVERY occurrence of their check, and the
1016
- // generic R6 ladder sits AFTER them in this priority array — R6 is only
1017
- // evaluated when no earlier rule fires, which a persistent offender's
1018
- // round never is. The documented "same-check rejections accumulate toward
1019
- // R6" (per-check counter the session maintains) was therefore unreachable
1020
- // and a repeat offender was rejected forever without a terminating step.
1021
- // Escalate these rules' rejects on the session's consecutive-rejection
1022
- // count, mirroring the R8/R7 three-strike rhythm: reject, reject
1023
- // (escalation notice), terminate on the third consecutive occurrence.
1024
- // Rules with their own ladder (R4/R5/R6/R7/R8/R9/R-C1/R-C2) are untouched.
1025
- const UNLADDERED_REJECT_CHECKS = new Set([
1026
- "success_with_remaining_criteria", // R1
1027
- "recurring_violation", // R2
1028
- "empty_success", // R3
1029
- "required_command_failed", // R-EVID
1030
- "command_evidence_mismatch", // R-EVID
1031
- "outcome_success_contradiction", // R-EVID
1032
- "verification_entrypoint_modified", // R-EVID-VERIFY
1033
- ]);
1076
+ // v3.7: uniform-ladder escalation. Rows without their own escalation
1077
+ // ladder (R1/R2/R-EVID/R-EVID-VERIFY) escalate on the session's
1078
+ // consecutive-rejection count: repeat rejections carry the escalation
1079
+ // notice (when enabled — the pre-v3.7 comment promised this notice but the
1080
+ // UNLADDERED name-set patch never appended it), and a third consecutive
1081
+ // occurrence terminates. This replaces the UNLADDERED_REJECT_CHECKS patch,
1082
+ // which existed because the generic R6 ladder sits AFTER these rows and a
1083
+ // persistent offender's round never reached it. Rows with their own ladder
1084
+ // (RULE_TABLE.ladder === "internal" / "counter") are untouched.
1034
1085
  for (const rule of rules) {
1035
- const result = rule();
1086
+ let result = rule();
1036
1087
  if (!result)
1037
1088
  continue;
1038
- if (result.action === "reject" &&
1039
- consecutiveRejections >= 2 &&
1040
- result.check !== undefined &&
1041
- UNLADDERED_REJECT_CHECKS.has(result.check)) {
1042
- return makeEnforcementResult({
1043
- action: "terminate",
1044
- reason: `${consecutiveRejections + 1} consecutive rejections for the same ` +
1045
- `issue (${result.check}) without resolution — the agent cannot ` +
1046
- `correct it even after escalation.`,
1047
- check: result.check,
1048
- });
1089
+ const row = result.check !== undefined
1090
+ ? RULE_TABLE_BY_ID.get(result.check)
1091
+ : undefined;
1092
+ if (result.action === "reject" && row && row.ladder === "uniform") {
1093
+ // L4: uniform rows escalate on their OWN consecutive streak only.
1094
+ // consecutiveRejections is the streak of the PREVIOUS round's check
1095
+ // (the coordinator resets it to 1 whenever the check changes) — a
1096
+ // streak earned by a DIFFERENT check must not convert this first
1097
+ // occurrence into termination. The user_gate row is the sharp case:
1098
+ // its recovery is human approval, so a gate cited after unrelated
1099
+ // rejections must reject with instructions, not kill the loop.
1100
+ const ownStreak = lastRejectionCheck === result.check
1101
+ ? consecutiveRejections
1102
+ : 0;
1103
+ if (ownStreak >= (row.terminalAfter ?? 2)) {
1104
+ return makeEnforcementResult({
1105
+ action: "terminate",
1106
+ reason: `${ownStreak + 1} consecutive rejections for the same ` +
1107
+ `issue (${result.check}) without resolution — the agent cannot ` +
1108
+ `correct it even after escalation.`,
1109
+ check: result.check,
1110
+ });
1111
+ }
1112
+ if (ownStreak >= 1 && row.noticeOnRepeat &&
1113
+ getPolicy().engine.enforcement_escalation_enabled) {
1114
+ result = makeEnforcementResult({
1115
+ ...result,
1116
+ fix_instructions: result.fix_instructions + buildEscalationNotice(),
1117
+ });
1118
+ }
1049
1119
  }
1050
1120
  return result;
1051
1121
  }