loopforge 2.11.0 → 3.2.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 (117) hide show
  1. package/README.md +4 -4
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +158 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +21 -9
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +34 -16
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +16 -6
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +40 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +126 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/enforcement-gate.d.ts +8 -3
  18. package/dist/enforcement-gate.d.ts.map +1 -1
  19. package/dist/enforcement-gate.js +419 -92
  20. package/dist/enforcement-gate.js.map +1 -1
  21. package/dist/engine.d.ts +47 -7
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +303 -60
  24. package/dist/engine.js.map +1 -1
  25. package/dist/evidence-claims.d.ts +50 -0
  26. package/dist/evidence-claims.d.ts.map +1 -0
  27. package/dist/evidence-claims.js +134 -0
  28. package/dist/evidence-claims.js.map +1 -0
  29. package/dist/generate-schema.js +6 -1
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +41 -3
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +310 -38
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-projection.d.ts +26 -0
  40. package/dist/loop-projection.d.ts.map +1 -0
  41. package/dist/loop-projection.js +142 -0
  42. package/dist/loop-projection.js.map +1 -0
  43. package/dist/loop-store.d.ts +91 -41
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +243 -173
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/round-lifecycle.d.ts +216 -0
  48. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  49. package/dist/mcp/round-lifecycle.js +926 -0
  50. package/dist/mcp/round-lifecycle.js.map +1 -0
  51. package/dist/mcp/server.d.ts +3 -2
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +65 -9
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/mcp/session.d.ts +47 -139
  56. package/dist/mcp/session.d.ts.map +1 -1
  57. package/dist/mcp/session.js +284 -784
  58. package/dist/mcp/session.js.map +1 -1
  59. package/dist/mcp/tools.d.ts +23 -1
  60. package/dist/mcp/tools.d.ts.map +1 -1
  61. package/dist/mcp/tools.js +270 -79
  62. package/dist/mcp/tools.js.map +1 -1
  63. package/dist/policy-metrics.d.ts +11 -0
  64. package/dist/policy-metrics.d.ts.map +1 -1
  65. package/dist/policy-metrics.js +118 -21
  66. package/dist/policy-metrics.js.map +1 -1
  67. package/dist/policy.d.ts +26 -1
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +13 -2
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +34 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +320 -42
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +169 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +39 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts +5 -5
  80. package/dist/replay.d.ts.map +1 -1
  81. package/dist/replay.js +9 -8
  82. package/dist/replay.js.map +1 -1
  83. package/dist/round-coordinator.d.ts +6 -3
  84. package/dist/round-coordinator.d.ts.map +1 -1
  85. package/dist/round-coordinator.js +38 -19
  86. package/dist/round-coordinator.js.map +1 -1
  87. package/dist/round-driver.d.ts +9 -4
  88. package/dist/round-driver.d.ts.map +1 -1
  89. package/dist/round-driver.js +15 -7
  90. package/dist/round-driver.js.map +1 -1
  91. package/dist/round-transaction.d.ts +7 -3
  92. package/dist/round-transaction.d.ts.map +1 -1
  93. package/dist/round-transaction.js +93 -88
  94. package/dist/round-transaction.js.map +1 -1
  95. package/dist/self-eval.d.ts +31 -1
  96. package/dist/self-eval.d.ts.map +1 -1
  97. package/dist/self-eval.js +115 -12
  98. package/dist/self-eval.js.map +1 -1
  99. package/dist/storage.d.ts +3 -4
  100. package/dist/storage.d.ts.map +1 -1
  101. package/dist/storage.js +5 -4
  102. package/dist/storage.js.map +1 -1
  103. package/dist/token-utils.d.ts +20 -7
  104. package/dist/token-utils.d.ts.map +1 -1
  105. package/dist/token-utils.js +68 -9
  106. package/dist/token-utils.js.map +1 -1
  107. package/dist/verification-gate.d.ts +54 -2
  108. package/dist/verification-gate.d.ts.map +1 -1
  109. package/dist/verification-gate.js +433 -25
  110. package/dist/verification-gate.js.map +1 -1
  111. package/loop_policy.json +35 -8
  112. package/package.json +3 -2
  113. package/skills/perception/SKILL.md +24 -6
  114. package/dist/backends/interface.d.ts +0 -39
  115. package/dist/backends/interface.d.ts.map +0 -1
  116. package/dist/backends/interface.js +0 -6
  117. package/dist/backends/interface.js.map +0 -1
@@ -17,8 +17,12 @@
17
17
  * with stopReason "enforcement_terminated".
18
18
  */
19
19
  import { makeEnforcementResult } from "./protocol.js";
20
- import { entryRound } from "./verification-gate.js";
20
+ import { entryRound, machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION } from "./verification-gate.js";
21
+ import { effectiveSuccess } from "./self-eval.js";
22
+ import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
21
23
  import { getPolicy } from "./policy.js";
24
+ import { STABLE_ID_RE, isRecord } from "./token-utils.js";
25
+ import { isProcessResult } from "./round-transaction.js";
22
26
  // ═══════════════════════════════════════════════════════════════════════════
23
27
  // Helpers
24
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -45,28 +49,76 @@ function collectProgressByRound(vaultEntries, currentRound) {
45
49
  }
46
50
  return progressByRound;
47
51
  }
52
+ /** v3.2.1: R4/R5 progress window — after a backtrack committed for this
53
+ * round, the agent's redo submission is the freshest data point. The
54
+ * pre-rollback history (which triggered the stall) stays in the vault and
55
+ * the redo does not commit while it is being evaluated, so without this
56
+ * the window never changes: a genuinely fixed redo is rejected forever and
57
+ * the loop dies in a spurious terminate. A redo that is STILL stalled
58
+ * keeps the window flat and hits the normal reject → terminate ladder. */
59
+ function progressWindow(vaultEntries, currentRound, selfEval) {
60
+ const progressByRound = collectProgressByRound(vaultEntries, currentRound);
61
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
62
+ const pe = selfEval.execution_evidence?.progress_estimate;
63
+ if (typeof pe === "number")
64
+ progressByRound.set(currentRound, pe);
65
+ }
66
+ return progressByRound;
67
+ }
48
68
  // ═══════════════════════════════════════════════════════════════════════════
49
69
  // v2.10: Backtrack helpers
50
70
  // ═══════════════════════════════════════════════════════════════════════════
71
+ /** Extract the committed round decision's result from a vault entry.
72
+ * The round commit entry (`loop:<id>:r<N>:feedback`) carries the full
73
+ * decision under loop_lineage.round_transaction; lineage entries carry
74
+ * nothing at the top level. */
75
+ function committedRoundResult(entry) {
76
+ const lineage = isRecord(entry.loop_lineage) ? entry.loop_lineage : null;
77
+ const rt = lineage && isRecord(lineage.round_transaction)
78
+ ? lineage.round_transaction
79
+ : null;
80
+ if (!rt)
81
+ return null;
82
+ return isProcessResult(rt.result) ? rt.result : null;
83
+ }
84
+ /** True when a backtrack already committed at or after the current round —
85
+ * i.e. the loop is re-walking territory the system already rolled back
86
+ * once. Guards against the reject↔backtrack deadlock on persistent stalls. */
87
+ function hasCommittedBacktrack(vaultEntries, currentRound) {
88
+ return vaultEntries.some((entry) => {
89
+ if (entryRound(entry) < currentRound)
90
+ return false;
91
+ return committedRoundResult(entry)?.action === "backtrack";
92
+ });
93
+ }
51
94
  /** Check whether a vault entry represents a "clean" round that can serve
52
95
  * as a safe restore point. A clean round was accepted (not rejected)
53
- * and had no error-level verification flags. */
96
+ * and had no error-level verification flags.
97
+ *
98
+ * v3.2.1: judged by the COMMITTED decision and gate flags, not by the
99
+ * agent's self-reported success. A progress-stall round commits with
100
+ * success=false but is accepted and carries no error flags — it is a
101
+ * valid backtrack target (the most common backtrack trigger is exactly a
102
+ * run of such rounds). Rejected/terminated rounds never commit, and
103
+ * rolled-back rounds are excluded below. */
54
104
  function isCleanRound(entry) {
55
- const lineage = entry.loop_lineage;
56
- // Rejected rounds are not clean
57
- const success = typeof entry.success === "boolean"
58
- ? entry.success
59
- : typeof lineage?.success === "boolean" ? lineage.success : undefined;
60
- if (success === false)
105
+ const committed = committedRoundResult(entry);
106
+ // A rolled-back round (already backtracked) is not a valid restore point.
107
+ if (committed?.action === "backtrack")
61
108
  return false;
109
+ // Rejected/terminated rounds are not clean. (They never commit in the
110
+ // real vault — this guards legacy or mixed-shape data.)
111
+ if (committed && (committed.action === "reject" || committed.action === "terminate")) {
112
+ return false;
113
+ }
62
114
  // Rounds with error-level verification flags are not clean
63
- const flags = entry.verification_flags;
64
- if (Array.isArray(flags)) {
65
- for (const f of flags) {
66
- if (f !== null && typeof f === "object" &&
67
- f.severity === "error")
68
- return false;
69
- }
115
+ const flags = Array.isArray(entry.verification_flags)
116
+ ? entry.verification_flags
117
+ : committed?.verificationFlags ?? [];
118
+ for (const f of flags) {
119
+ if (f !== null && typeof f === "object" &&
120
+ f.severity === "error")
121
+ return false;
70
122
  }
71
123
  return true;
72
124
  }
@@ -74,10 +126,17 @@ function isCleanRound(entry) {
74
126
  * Collects discovered_constraints from skipped rounds along the way.
75
127
  * Returns null when no clean round is found within maxDepth. */
76
128
  export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
129
+ // v3.2.1: exclude compile-time lineage entries — every committed round
130
+ // writes one (engine.ts hardcodes `success: true` on it, and it carries no
131
+ // round_transaction), and it precedes the feedback entry in the flat view.
132
+ // Treating it as a decision made every round look clean, so the restore
133
+ // point was always currentRound-1: dirty/rejected rounds were never
134
+ // skipped, depth > 1 never collected skippedDiscoveries, and the restore
135
+ // prompt's file list (round-coordinator) was always empty.
77
136
  const completed = vaultEntries
78
137
  .filter((e) => {
79
138
  const rnd = entryRound(e);
80
- return rnd >= 1 && rnd < currentRound;
139
+ return rnd >= 1 && rnd < currentRound && e.task_type !== "loop_lineage";
81
140
  })
82
141
  .sort((a, b) => entryRound(b) - entryRound(a)); // newest first
83
142
  const skippedDiscoveries = [];
@@ -110,6 +169,37 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
110
169
  }
111
170
  return null;
112
171
  }
172
+ /** v2.12: Git HEAD commit hash at the restore point. Read from the restore
173
+ * round's committed transaction snapshot (after evidence preferred, before
174
+ * evidence as fallback). Returns null when the round has no git snapshot
175
+ * or no committed feedback entry. */
176
+ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
177
+ const feedback = vaultEntries.find((entry) => String(entry.task_id ?? "") === `loop:${entry.loop_id}:r${restoreRound}:feedback`);
178
+ const lineage = feedback && isRecord(feedback.loop_lineage)
179
+ ? feedback.loop_lineage
180
+ : null;
181
+ const transaction = lineage && isRecord(lineage.round_transaction)
182
+ ? lineage.round_transaction
183
+ : null;
184
+ const snapshot = transaction && isRecord(transaction.snapshot)
185
+ ? transaction.snapshot
186
+ : null;
187
+ if (!snapshot)
188
+ return null;
189
+ const evidence = Array.isArray(snapshot.afterEvidence) && snapshot.afterEvidence.length > 0
190
+ ? snapshot.afterEvidence
191
+ : Array.isArray(snapshot.beforeEvidence)
192
+ ? snapshot.beforeEvidence
193
+ : [];
194
+ for (const item of evidence) {
195
+ if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
196
+ continue;
197
+ const head = item.data.head;
198
+ if (typeof head === "string" && head.length > 0)
199
+ return head;
200
+ }
201
+ return null;
202
+ }
113
203
  /** Build the backtrack prompt injected at the top of the restored round.
114
204
  *
115
205
  * v2.13: Includes concrete workspace restore instructions with affected
@@ -119,9 +209,9 @@ export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDis
119
209
  /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
120
210
  * Used to show the agent exactly what needs to be reverted. */
121
211
  skippedFiles = [],
122
- /** v2.13: Git HEAD commit hash at the backtrack point (current HEAD).
212
+ /** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
123
213
  * The agent must discard work back to the clean round's state. */
124
- _gitHead) {
214
+ gitHead) {
125
215
  const lines = [
126
216
  `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
127
217
  "",
@@ -151,7 +241,14 @@ _gitHead) {
151
241
  }
152
242
  lines.push("");
153
243
  }
154
- 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", "```", "", `**If git is unavailable**, manually revert the files listed above ` +
244
+ lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "");
245
+ // v2.12: Restore to the exact commit observed at the clean round.
246
+ if (gitHead) {
247
+ lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
248
+ "gate checks that your working tree returns to this commit before " +
249
+ "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.", "");
250
+ }
251
+ lines.push(`**If git is unavailable**, manually revert the files listed above ` +
155
252
  `to their state at Round ${toRound}. Re-read the state file below — ` +
156
253
  `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
157
254
  "> your workspace no longer contains the failed changes. Submissions " +
@@ -183,7 +280,7 @@ _gitHead) {
183
280
  * set success=false honestly. Triggered by the verification gate's
184
281
  * "success_with_remaining_criteria" error flag. */
185
282
  function enforceSuccessWithRemainingCriteria(flags) {
186
- const flag = flags.find((f) => f.check === "success_with_remaining_criteria" && f.severity === "error");
283
+ const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITH_REMAINING_CRITERIA && f.severity === "error");
187
284
  if (!flag)
188
285
  return null;
189
286
  return makeEnforcementResult({
@@ -199,7 +296,7 @@ function enforceSuccessWithRemainingCriteria(flags) {
199
296
  * The agent is repeating the same mistake. Triggered by the verification
200
297
  * gate's "recurring_violation" error flag. */
201
298
  function enforceRecurringViolation(flags) {
202
- const flag = flags.find((f) => f.check === "recurring_violation" && f.severity === "error");
299
+ const flag = flags.find((f) => f.check === CHECK_RECURRING_VIOLATION && f.severity === "error");
203
300
  if (!flag)
204
301
  return null;
205
302
  return makeEnforcementResult({
@@ -212,6 +309,30 @@ function enforceRecurringViolation(flags) {
212
309
  check: "recurring_violation",
213
310
  });
214
311
  }
312
+ /** R-EVID (v2.14): A required command failure, a test-results mismatch that
313
+ * hides failures, or a self-contradictory outcome claim contradicts the
314
+ * success claim before commit. The verification gate detects the
315
+ * contradiction; this rule decides what to do about it — reject so the
316
+ * agent redos the SAME round with honest claims. Same-check rejections
317
+ * accumulate toward R6 via the per-check counter. */
318
+ function enforceEvidenceContradiction(flags) {
319
+ const contradiction = flags.find((f) => f.severity === "error" &&
320
+ (f.check === CHECK_REQUIRED_COMMAND_FAILED ||
321
+ f.check === CHECK_COMMAND_EVIDENCE_MISMATCH ||
322
+ f.check === CHECK_OUTCOME_SUCCESS_CONTRADICTION));
323
+ if (!contradiction)
324
+ return null;
325
+ return makeEnforcementResult({
326
+ action: "reject",
327
+ reason: contradiction.detail,
328
+ fix_instructions: "A required verification command failed or your claims contradict " +
329
+ "the evidence. You must: (a) run the required command and report its " +
330
+ "actual output, (b) reconcile your success claim with the evidence — " +
331
+ "do NOT report success when the evidence contradicts it, and " +
332
+ "(c) resubmit an honest SelfEvaluation for the SAME round.",
333
+ check: contradiction.check,
334
+ });
335
+ }
215
336
  /** R3: Agent claims success but did nothing verifiable.
216
337
  * v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
217
338
  * Missing evidence when success=true → reject (agent must provide evidence).
@@ -219,7 +340,7 @@ function enforceRecurringViolation(flags) {
219
340
  function enforceEmptySuccess(selfEval, _flags, skipEvidenceRules = false) {
220
341
  if (skipEvidenceRules)
221
342
  return null; // Requires execution_evidence
222
- if (!selfEval.success)
343
+ if (!effectiveSuccess(selfEval))
223
344
  return null;
224
345
  const ev = selfEval.execution_evidence;
225
346
  // v1.17: Missing evidence when claiming success → reject.
@@ -255,35 +376,112 @@ function enforceEmptySuccess(selfEval, _flags, skipEvidenceRules = false) {
255
376
  check: "empty_success",
256
377
  });
257
378
  }
379
+ /** R8 (v2.12): Success claimed without any machine-verifiable evidence.
380
+ * The verification gate produced success_without_verified_evidence (error):
381
+ * zero verified claims, no test results. First occurrence → reject with
382
+ * concrete evidence requirements; second consecutive → terminate. */
383
+ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
384
+ const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
385
+ if (!flag)
386
+ return null;
387
+ if (consecutiveRejections >= 2) {
388
+ return makeEnforcementResult({
389
+ action: "terminate",
390
+ reason: "Repeated success claims with zero machine-verifiable evidence.",
391
+ check: "success_without_verified_evidence",
392
+ });
393
+ }
394
+ const escalation = getPolicy().engine.enforcement_escalation_enabled &&
395
+ consecutiveRejections >= 1
396
+ ? buildEscalationNotice()
397
+ : "";
398
+ return makeEnforcementResult({
399
+ action: "reject",
400
+ reason: "Agent claims success but the runtime verified zero claims: no passing " +
401
+ "test evidence and no test results were observed. Success must be backed " +
402
+ "by machine-verifiable evidence.",
403
+ fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
404
+ "report results in execution_evidence.test_results, or (b) configure a " +
405
+ "required verification command and run it, or (c) if no change was " +
406
+ "genuinely needed, declare no_change_reason explaining why." + escalation,
407
+ check: "success_without_verified_evidence",
408
+ });
409
+ }
410
+ /** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
411
+ * produced backtrack_workspace_not_restored (error): either skipped-round
412
+ * files are still dirty, or the working tree is not at the restore point's
413
+ * git HEAD. The agent must restore the workspace, not redo the round. */
414
+ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0) {
415
+ const flag = flags.find((f) => f.check === CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED && f.severity === "error");
416
+ if (!flag)
417
+ return null;
418
+ if (!getPolicy().engine.backtrack_enabled)
419
+ return null;
420
+ // v2.14: same deadlock guard as R4/R5 — a backtrack that already fired
421
+ // for this round means a non-restoring agent would otherwise cycle
422
+ // R9-backtrack forever (backtracks do not increment the rejection
423
+ // counter). One rollback chance, then terminate.
424
+ if (currentRound > 0 && hasCommittedBacktrack(vaultEntries, currentRound)) {
425
+ return makeEnforcementResult({
426
+ action: "terminate",
427
+ reason: "The workspace is still not restored after the previous backtrack: " +
428
+ flag.detail,
429
+ check: "backtrack_workspace_not_restored",
430
+ });
431
+ }
432
+ return makeEnforcementResult({
433
+ action: "backtrack",
434
+ reason: "The workspace was not restored after backtrack: " + flag.detail,
435
+ fix_instructions: "Restore the working directory to the clean round's state " +
436
+ "(git checkout -- . && git clean -fd, or git stash; if the failed " +
437
+ "rounds created commits, git reset --hard <restore-commit>) before " +
438
+ "submitting the next round.",
439
+ check: "backtrack_workspace_not_restored",
440
+ });
441
+ }
258
442
  /** R4: Progress has stalled for 3+ consecutive rounds.
259
443
  * Detected by checking progress_estimate deltas across vault entries.
260
444
  * First occurrence → REJECT (agent must change approach).
261
445
  * Second consecutive occurrence → TERMINATE (agent cannot recover). */
262
- function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
263
- if (skipEvidenceRules)
264
- return null; // Requires execution_evidence
446
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
265
447
  // Need at least 3 rounds of history
266
448
  if (currentRound < 3)
267
449
  return null;
268
- const progressByRound = collectProgressByRound(vaultEntries, currentRound);
269
- if (progressByRound.size < 3)
270
- return null;
271
- const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
272
- const last3 = sortedRounds.slice(-3);
273
- // Require all three data points to be within [currentRound-3, currentRound-1].
274
- const expectedMin = currentRound - 3;
275
- if (last3[0] < expectedMin || last3[2] > currentRound - 1)
276
- return null;
277
- const p1 = progressByRound.get(last3[0]);
278
- const p2 = progressByRound.get(last3[1]);
279
- const p3 = progressByRound.get(last3[2]);
280
- const stallThreshold = getPolicy().evolution.progress_stall_threshold;
281
- const delta12 = p2 - p1;
282
- const delta23 = p3 - p2;
283
- // Both deltas must be below threshold AND not near completion
284
- const isStalling = delta12 < stallThreshold &&
285
- delta23 < stallThreshold &&
286
- p3 < 0.95;
450
+ let isStalling = false;
451
+ let stallDetail = "";
452
+ let stallThreshold = 0;
453
+ if (skipEvidenceRules) {
454
+ // v3.2: heuristic path has no execution_evidence — fall back to machine
455
+ // observations: three consecutive rounds with no git change = stall.
456
+ // No git signal → keep the legacy skip (the machine cannot observe).
457
+ const machine = machineProgressSeries(vaultEntries, currentRound, 3);
458
+ if (machine === null)
459
+ return null;
460
+ isStalling = machine.every((value) => !value);
461
+ stallDetail = "no machine-observed changes in the last 3 rounds";
462
+ }
463
+ else {
464
+ const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
465
+ if (progressByRound.size < 3)
466
+ return null;
467
+ const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
468
+ const last3 = sortedRounds.slice(-3);
469
+ // Require all three data points to be within [currentRound-3, currentRound-1].
470
+ // (collectProgressByRound already filters rnd < currentRound, so the upper
471
+ // bound is always satisfied — the lower bound is the real continuity guard.)
472
+ const expectedMin = currentRound - 3;
473
+ if (last3[0] < expectedMin)
474
+ return null;
475
+ const p1 = progressByRound.get(last3[0]);
476
+ const p2 = progressByRound.get(last3[1]);
477
+ const p3 = progressByRound.get(last3[2]);
478
+ stallThreshold = getPolicy().evolution.progress_stall_threshold;
479
+ const delta12 = p2 - p1;
480
+ const delta23 = p3 - p2;
481
+ // Both deltas must be below threshold AND not near completion
482
+ isStalling = delta12 < stallThreshold && delta23 < stallThreshold && p3 < 0.95;
483
+ stallDetail = `${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%`;
484
+ }
287
485
  if (!isStalling)
288
486
  return null;
289
487
  const escalation = getPolicy().engine.enforcement_escalation_enabled;
@@ -294,7 +492,7 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
294
492
  return makeEnforcementResult({
295
493
  action: "terminate",
296
494
  reason: `Progress stalled for 3+ rounds ` +
297
- `(${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%) ` +
495
+ `(${stallDetail}) ` +
298
496
  `and agent did not resolve after escalation. Terminating loop.`,
299
497
  check: "progress_stall",
300
498
  });
@@ -304,11 +502,23 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
304
502
  // reject. The agent is rolled back to the last clean round with lessons
305
503
  // injected, rather than redoing the same round with an escalation notice.
306
504
  if (getPolicy().engine.backtrack_enabled) {
505
+ // v2.14: A backtrack that already committed for this round (the loop
506
+ // is re-walking rolled-back territory with the same stall) must not
507
+ // loop forever — terminate instead. Without this, consecutive
508
+ // rejections reset on every backtrack and the R4 cycle has no exit.
509
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
510
+ return makeEnforcementResult({
511
+ action: "terminate",
512
+ reason: `Progress has stalled over the last 3 rounds ` +
513
+ `(${stallDetail}) and a backtrack already fired for ` +
514
+ `this round. The stall persists after rollback — terminating.`,
515
+ check: "progress_stall",
516
+ });
517
+ }
307
518
  return makeEnforcementResult({
308
519
  action: "backtrack",
309
- reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
310
- `${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
311
- `last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
520
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
521
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
312
522
  fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
313
523
  "to the last clean round. Do NOT repeat the approach that led to the stall. " +
314
524
  "Choose a different technique or task decomposition.",
@@ -317,9 +527,8 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
317
527
  }
318
528
  return makeEnforcementResult({
319
529
  action: "reject",
320
- reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
321
- `${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
322
- `last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
530
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
531
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
323
532
  fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
324
533
  "(a) explain what is blocking progress, " +
325
534
  "(b) propose a DIFFERENT technique or task decomposition, and " +
@@ -333,16 +542,15 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
333
542
  return makeEnforcementResult({
334
543
  action: "terminate",
335
544
  reason: `Progress stalled for 3+ rounds ` +
336
- `(${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%) ` +
545
+ `(${stallDetail}) ` +
337
546
  `and agent did not resolve after previous rejection. Terminating loop.`,
338
547
  check: "progress_stall",
339
548
  });
340
549
  }
341
550
  return makeEnforcementResult({
342
551
  action: "reject",
343
- reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
344
- `${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
345
- `last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
552
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
553
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
346
554
  fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
347
555
  "(a) explain what is blocking progress, " +
348
556
  "(b) propose a DIFFERENT technique or task decomposition, and " +
@@ -361,24 +569,45 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
361
569
  * Termination only happens on the second consecutive flatline detection.
362
570
  *
363
571
  * Uses the engine.max_circuit_breaker policy value as the lookback window. */
364
- function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
365
- if (skipEvidenceRules)
366
- return null; // Requires execution_evidence
572
+ function enforceProgressStallTerminal(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
367
573
  const breakerSize = getPolicy().engine.max_circuit_breaker;
368
574
  if (currentRound < breakerSize || vaultEntries.length < breakerSize)
369
575
  return null;
370
- const progressByRound = collectProgressByRound(vaultEntries, currentRound);
371
- if (progressByRound.size < breakerSize)
372
- return null;
373
- const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
374
- const recent = sortedRounds.slice(-breakerSize);
375
- // Check that progress values are ALL identical (delta = 0).
376
- // Use epsilon comparison — JSON round-trips can produce float drift
377
- // (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
378
- const EPSILON = 1e-10;
379
- const first = progressByRound.get(recent[0]);
380
- const allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
381
- if (!allFlat || first >= 0.95)
576
+ let allFlat = false;
577
+ let flatDetail = "";
578
+ if (skipEvidenceRules) {
579
+ // v3.2: heuristic path has no execution_evidence — fall back to machine
580
+ // observations: N consecutive rounds with no git change = flatline.
581
+ // No git signal → keep the legacy skip (the machine cannot observe).
582
+ const machine = machineProgressSeries(vaultEntries, currentRound, breakerSize);
583
+ if (machine === null)
584
+ return null;
585
+ allFlat = machine.every((value) => !value);
586
+ flatDetail = `no machine-observed changes for ${breakerSize} consecutive rounds`;
587
+ }
588
+ else {
589
+ const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
590
+ if (progressByRound.size < breakerSize)
591
+ return null;
592
+ const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
593
+ const recent = sortedRounds.slice(-breakerSize);
594
+ // v2.14: continuity guard (mirrors R4) — "consecutive" means the most
595
+ // recent rounds. Rounds without execution_evidence are unknown motion,
596
+ // not zero motion; taking "the last N rounds that happened to report
597
+ // progress" terminated loops whose recent rounds simply had no data.
598
+ if (recent[0] < currentRound - breakerSize)
599
+ return null;
600
+ // Check that progress values are ALL identical (delta = 0).
601
+ // Use epsilon comparison — JSON round-trips can produce float drift
602
+ // (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
603
+ const EPSILON = 1e-10;
604
+ const first = progressByRound.get(recent[0]);
605
+ allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
606
+ if (!allFlat || first >= 0.95)
607
+ return null;
608
+ flatDetail = `completely flat at ${(first * 100).toFixed(0)}%`;
609
+ }
610
+ if (!allFlat)
382
611
  return null;
383
612
  const escalation = getPolicy().engine.enforcement_escalation_enabled;
384
613
  // v2.7: When escalation is enabled, give one rejection round with a
@@ -386,7 +615,7 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
386
615
  if (consecutiveRejections >= 1) {
387
616
  return makeEnforcementResult({
388
617
  action: "terminate",
389
- reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
618
+ reason: `Progress has been ${flatDetail} ` +
390
619
  `for ${breakerSize} consecutive rounds and agent did not resolve ` +
391
620
  `after escalation. The agent is making zero forward motion.`,
392
621
  check: "progress_stall_terminal",
@@ -397,9 +626,21 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
397
626
  // reject. The agent is rolled back to the last clean round with a
398
627
  // "radically different strategy" lesson injected.
399
628
  if (getPolicy().engine.backtrack_enabled) {
629
+ // v2.14: Same deadlock guard as R4 — a backtrack that already fired
630
+ // for this round means the loop is re-walking rolled-back territory.
631
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
632
+ return makeEnforcementResult({
633
+ action: "terminate",
634
+ reason: `Progress has been ${flatDetail} ` +
635
+ `for ${breakerSize} consecutive rounds and a backtrack already ` +
636
+ `fired for this round. Flatline persists after rollback — ` +
637
+ `terminating the loop.`,
638
+ check: "progress_stall_terminal",
639
+ });
640
+ }
400
641
  return makeEnforcementResult({
401
642
  action: "backtrack",
402
- reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
643
+ reason: `Progress has been ${flatDetail} ` +
403
644
  `for ${breakerSize} consecutive rounds. The agent is making zero ` +
404
645
  `forward motion.`,
405
646
  fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
@@ -410,7 +651,7 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
410
651
  }
411
652
  return makeEnforcementResult({
412
653
  action: "reject",
413
- reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
654
+ reason: `Progress has been ${flatDetail} ` +
414
655
  `for ${breakerSize} consecutive rounds. The agent is making zero ` +
415
656
  `forward motion.`,
416
657
  fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
@@ -424,7 +665,7 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
424
665
  }
425
666
  return makeEnforcementResult({
426
667
  action: "terminate",
427
- reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
668
+ reason: `Progress has been ${flatDetail} ` +
428
669
  `for ${breakerSize} consecutive rounds. The agent is making zero ` +
429
670
  `forward motion and cannot recover.`,
430
671
  check: "progress_stall_terminal",
@@ -477,15 +718,74 @@ function enforceMaxRejections(consecutiveRejections) {
477
718
  * anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
478
719
  * reference. This distinguishes genuine explanations from filler text that
479
720
  * just happens to be ≥ 20 characters. */
480
- function hasSemanticAnchor(clarification) {
481
- // v2.11: Constraint / criterion / sub-goal ID patterns
482
- if (/[c]r?-[a-f0-9]{8}/.test(clarification))
483
- return true;
484
- if (/sg-[a-f0-9]{8}/.test(clarification))
485
- return true;
486
- // File path references (e.g. "src/auth/login.ts", "lib/utils.go")
487
- if (/[\w./-]+\.[a-z]{2,6}\b/i.test(clarification))
488
- return true;
721
+ /** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
722
+ * file-path-like tokens. */
723
+ function extractAnchors(clarification) {
724
+ const anchors = new Set();
725
+ for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
726
+ anchors.add(match[0].toLowerCase());
727
+ }
728
+ for (const match of clarification.matchAll(/[\w./-]+\.[a-z]{2,6}\b/gi)) {
729
+ anchors.add(match[0]);
730
+ }
731
+ return [...anchors];
732
+ }
733
+ /** v2.12: A drift clarification is substantive only when at least one anchor
734
+ * is REAL — a stable ID that derives from a known constraint/criterion/
735
+ * sub-goal text in the vault, or a file path present in reported/observed
736
+ * files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
737
+ * count, closing the v2.12 filler-text loophole. */
738
+ function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
739
+ const anchors = extractAnchors(clarification);
740
+ if (anchors.length === 0)
741
+ return false;
742
+ const knownFiles = new Set();
743
+ const knownTexts = new Set();
744
+ const collect = (values) => {
745
+ if (!Array.isArray(values))
746
+ return;
747
+ for (const value of values) {
748
+ if (typeof value === "string" && value.length > 0)
749
+ knownTexts.add(value);
750
+ }
751
+ };
752
+ // The agent's own round report is a known set: its constraints, criteria,
753
+ // and files are legitimate anchor targets.
754
+ for (const file of selfEval.execution_evidence?.files_changed ?? [])
755
+ knownFiles.add(file);
756
+ collect(selfEval.discovered_constraints);
757
+ collect(selfEval.constraint_violations);
758
+ const selfEvidence = selfEval.execution_evidence;
759
+ if (selfEvidence) {
760
+ collect(selfEvidence.success_criteria_met);
761
+ collect(selfEvidence.success_criteria_remaining);
762
+ }
763
+ for (const entry of vaultEntries) {
764
+ collect(entry.constraint_violations);
765
+ collect(entry.discovered_constraints);
766
+ const ev = entry.execution_evidence;
767
+ if (ev) {
768
+ collect(ev.files_changed);
769
+ collect(ev.success_criteria_met);
770
+ collect(ev.success_criteria_remaining);
771
+ }
772
+ }
773
+ for (const anchor of anchors) {
774
+ if (STABLE_ID_RE.test(anchor)) {
775
+ const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
776
+ ? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
777
+ : anchor.startsWith("cr-")
778
+ ? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
779
+ : [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
780
+ if (matches)
781
+ return true;
782
+ continue;
783
+ }
784
+ // File path: exact or suffix match against known files.
785
+ if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
786
+ return true;
787
+ }
788
+ }
489
789
  return false;
490
790
  }
491
791
  /** Build a stronger rejection notice as the clarification streak grows. */
@@ -522,13 +822,16 @@ function buildClarificationRejectionNotice(streak) {
522
822
  *
523
823
  * First occurrence → reject (agent must explain the gap or realign).
524
824
  * Second consecutive → terminate (agent cannot/will not follow its own plan). */
525
- function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0) {
825
+ function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
526
826
  const flag = flags.find((f) => f.check === "intent_drift");
527
827
  if (!flag)
528
828
  return null;
529
829
  const clarification = selfEval.drift_clarification?.trim();
530
- // v2.12: Semantic anchor detection — requires concrete references
531
- const hasAnchor = clarification ? hasSemanticAnchor(clarification) : false;
830
+ // v2.12: Substantive anchors — the referenced ID/path must actually exist
831
+ // in the vault or in reported files; a fabricated ID no longer counts.
832
+ const hasAnchor = clarification
833
+ ? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
834
+ : false;
532
835
  // v2.12: Substantive clarification = length check + semantic anchor
533
836
  const isSubstantive = clarification
534
837
  && clarification.length >= 20
@@ -574,13 +877,30 @@ function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarifi
574
877
  check: "intent_drift",
575
878
  });
576
879
  }
577
- // No clarification or too short — original behavior
578
- if (consecutiveRejections >= 1) {
880
+ // No clarification or too short — the worst form of drift. v2.14: counts
881
+ // toward the SAME streak as weak clarifications (maxStreak consecutive
882
+ // un-explained drifts terminate, per the README's "three consecutive weak
883
+ // clarifications"). Previously this branch used the GLOBAL
884
+ // consecutiveRejections counter, so an unrelated earlier rejection
885
+ // (e.g. R1) terminated the loop on the very first drift.
886
+ const maxStreak = getPolicy().engine.drift_clarification_max_streak;
887
+ // When maxStreak is 0, disable the check (pre-v2.12 behavior)
888
+ if (maxStreak <= 0) {
889
+ return makeEnforcementResult({
890
+ action: "accept",
891
+ reason: "",
892
+ fix_instructions: "",
893
+ check: "intent_drift",
894
+ clarification_accepted: true,
895
+ });
896
+ }
897
+ const newStreak = driftClarificationStreak + 1;
898
+ if (newStreak >= maxStreak) {
579
899
  return makeEnforcementResult({
580
900
  action: "terminate",
581
- reason: `Agent was warned about intent-action drift but continues to do ` +
582
- `work unrelated to its declared next_action without explanation. ` +
583
- `The agent cannot or will not follow its own plan — terminating loop.`,
901
+ reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
902
+ `consecutive rounds without any drift_clarification. The agent ` +
903
+ `cannot or will not follow its own plan — terminating loop.`,
584
904
  check: "intent_drift",
585
905
  });
586
906
  }
@@ -674,10 +994,17 @@ driftClarificationStreak = 0) {
674
994
  () => enforceSuccessWithRemainingCriteria(flags),
675
995
  () => enforceRecurringViolation(flags),
676
996
  () => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
997
+ // v2.14: R-EVID — a required command failure or self-contradictory
998
+ // outcome claim contradicts the success claim before commit
999
+ () => enforceEvidenceContradiction(flags),
1000
+ // v2.12: R8 — success with zero machine-verifiable evidence
1001
+ () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
677
1002
  () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
678
1003
  () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
679
1004
  () => enforceMaxRejections(consecutiveRejections),
680
- () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak),
1005
+ () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1006
+ // v2.12: R9 — post-backtrack workspace not restored
1007
+ () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
681
1008
  ];
682
1009
  for (const rule of rules) {
683
1010
  const result = rule();